Skip to content

Commit 2ab8bf8

Browse files
fubuloubuantazoey
andauthored
feat: add delegates (#41)
* feat(API): add delegate support to base client * feat(API): add delegate implementation to mock client * feat(API): add delegates implementation to Safe API client * feat(CLI): add support for viewing and modifying delegates * fix: API uses `.signHash` raw hash signing (dangerous) * fix(API): client accepts empty signatures * feat(account): add method to propose a SafeTx directly * test: add tests for delegate feature (using MockClient) * refactor: apply suggestions from code review Co-authored-by: antazoey <admin@antazoey.me> * refactor: use asserts rather than excepiton for mock * refactor: don't add extra conversion logic in mock * refactor(CLI): change `ERROR` to `INFO` for no delegates found * refactor: use new `AccountAPI.sign_raw_msghash` method * fix: forgot to update removal too * refactor: use `account_option` instead of `callback_factory` * fix: unused imports * fix: asserts were wrong * fix: use SafeClientException to mimic real environment * chore: ignore not helpful logs to reduce display overload for testing * chore: run ape tests and integration tests with different commands * feat: add `SafeAccount.propose` as a shortcut to propose to Safe API * fix: flake8 errors * fix(API): use trailing slashes everywhere --------- Co-authored-by: antazoey <admin@antazoey.me>
1 parent 67bcba6 commit 2ab8bf8

10 files changed

Lines changed: 323 additions & 22 deletions

File tree

.github/workflows/test.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,8 @@ jobs:
8888
python -m pip install --upgrade pip
8989
pip install .[test]
9090
91-
- name: Run Tests
92-
run: ape test -n 0 -s --cov
91+
- name: Run Functional Tests
92+
run: ape test -n 0 tests/functional/ -s --cov -v WARNING
93+
94+
- name: Run Integration Tests
95+
run: ape test -n 0 tests/integration/ -s --cov -v WARNING

ape_safe/_cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import click
22

3+
from ape_safe._cli.delegates import delegates
34
from ape_safe._cli.pending import pending
45
from ape_safe._cli.safe_mgmt import _list, add, all_txns, remove
56

@@ -17,3 +18,4 @@ def cli():
1718
cli.add_command(remove)
1819
cli.add_command(all_txns)
1920
cli.add_command(pending)
21+
cli.add_command(delegates)

ape_safe/_cli/delegates.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import click
2+
from ape.cli import ConnectedProviderCommand, account_option
3+
from ape.types import AddressType
4+
from eth_typing import ChecksumAddress
5+
6+
from ape_safe._cli.click_ext import safe_argument, safe_cli_ctx, safe_option
7+
8+
9+
@click.group()
10+
def delegates():
11+
"""
12+
View and configure delegates
13+
"""
14+
15+
16+
@delegates.command("list", cls=ConnectedProviderCommand)
17+
@safe_cli_ctx()
18+
@safe_argument
19+
def _list(cli_ctx, safe):
20+
"""
21+
Show delegates for signers in a Safe
22+
"""
23+
if delegates := safe.client.get_delegates():
24+
cli_ctx.logger.success(f"Found delegates for {safe.address} ({safe.alias})")
25+
for delegator in delegates:
26+
click.echo(f"\nSigner {delegator}:")
27+
click.echo("- " + "\n- ".join(delegates[delegator]))
28+
29+
else:
30+
cli_ctx.logger.info(f"No delegates for {safe.address} ({safe.alias})")
31+
32+
33+
@delegates.command(cls=ConnectedProviderCommand)
34+
@safe_cli_ctx()
35+
@safe_option
36+
@click.argument("delegate", type=ChecksumAddress)
37+
@click.argument("label")
38+
@account_option()
39+
def add(cli_ctx, safe, delegate, label, account):
40+
"""
41+
Add a delegate for a signer in a Safe
42+
"""
43+
delegate = cli_ctx.conversion_manager.convert(delegate, AddressType)
44+
safe.client.add_delegate(delegate, label, account)
45+
cli_ctx.logger.success(
46+
f"Added delegate {delegate} ({label}) for {account.address} "
47+
f"in {safe.address} ({safe.alias})"
48+
)
49+
50+
51+
@delegates.command(cls=ConnectedProviderCommand)
52+
@safe_cli_ctx()
53+
@safe_option
54+
@click.argument("delegate", type=ChecksumAddress)
55+
@account_option()
56+
def remove(cli_ctx, safe, delegate, account):
57+
"""
58+
Remove a delegate for a specific signer in a Safe
59+
"""
60+
delegate = cli_ctx.conversion_manager.convert(delegate, AddressType)
61+
safe.client.remove_delegate(delegate, account)
62+
cli_ctx.logger.success(
63+
f"Removed delegate {delegate} for {account.address} in {safe.address} ({safe.alias})"
64+
)

ape_safe/accounts.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,60 @@ def create_safe_tx(self, txn: Optional[TransactionAPI] = None, **safe_tx_kwargs)
406406
}
407407
return self.safe_tx_def(**safe_tx)
408408

409+
def all_delegates(self) -> Iterator[AddressType]:
410+
for delegates in self.client.get_delegates().values():
411+
yield from delegates
412+
413+
def propose_safe_tx(
414+
self,
415+
safe_tx: SafeTx,
416+
submitter: Union[AccountAPI, AddressType, str, None] = None,
417+
sigs_by_signer: Optional[dict[AddressType, MessageSignature]] = None,
418+
contractTransactionHash: Optional[SafeTxID] = None,
419+
) -> SafeTxID:
420+
"""
421+
Propose a safe_tx to the Safe API client
422+
"""
423+
if not contractTransactionHash:
424+
contractTransactionHash = get_safe_tx_hash(safe_tx)
425+
426+
if not sigs_by_signer:
427+
sigs_by_signer = {}
428+
429+
if submitter is not None and not isinstance(submitter, AccountAPI):
430+
submitter = self.load_submitter(submitter)
431+
432+
if (
433+
submitter is not None
434+
and submitter.address not in sigs_by_signer
435+
and len(sigs_by_signer) < self.confirmations_required
436+
and (submitter.address in self.signers or submitter.address in self.all_delegates())
437+
):
438+
if sig := submitter.sign_message(safe_tx):
439+
sigs_by_signer[submitter.address] = sig
440+
441+
# NOTE: Signatures don't have to be in order for Safe API post
442+
self.client.post_transaction(
443+
safe_tx,
444+
sigs_by_signer,
445+
sender=submitter.address if submitter else None,
446+
contractTransactionHash=contractTransactionHash,
447+
)
448+
449+
return contractTransactionHash
450+
451+
def propose(
452+
self,
453+
txn: Optional[TransactionAPI] = None,
454+
submitter: Union[AccountAPI, AddressType, str, None] = None,
455+
**safe_tx_kwargs,
456+
) -> SafeTxID:
457+
"""
458+
Propose a transaction to the Safe API client
459+
"""
460+
safe_tx = self.create_safe_tx(txn=txn, **safe_tx_kwargs)
461+
return self.propose_safe_tx(safe_tx, submitter=submitter)
462+
409463
def pending_transactions(self) -> Iterator[tuple[SafeTx, list[SafeTxConfirmation]]]:
410464
for executed_tx in self.client.get_transactions(confirmed=False):
411465
yield self.create_safe_tx(
@@ -653,7 +707,7 @@ def sign_transaction(
653707
submit (bool): The option to submit the transaction. Defaults to ``True``.
654708
submitter (Union[``AccountAPI``, ``AddressType``, str, None]):
655709
Determine who is submitting the transaction. Defaults to ``None``.
656-
skip (Optional[List[Union[``AccountAPI, `AddressType``, str]]]):
710+
skip (Optional[list[Union[``AccountAPI, `AddressType``, str]]]):
657711
Allow bypassing any specified signer. Defaults to ``None``.
658712
signatures_required (Optional[int]):
659713
The amount of signers required to confirm the transaction. Defaults to ``None``.
@@ -758,12 +812,11 @@ def skip_signer(signer: AccountAPI):
758812
f"for Safe {self.address}#{safe_tx.nonce}" # TODO: put URI
759813
)
760814

761-
# NOTE: Signatures don't have to be in order for Safe API post
762-
self.client.post_transaction(
815+
self.propose_safe_tx(
763816
safe_tx,
764-
sigs_by_signer,
817+
submitter=submitter_account,
818+
sigs_by_signer=sigs_by_signer,
765819
contractTransactionHash=safe_tx_hash,
766-
sender=submitter_account.address,
767820
)
768821

769822
# Return None so that Ape does not try to submit the transaction.

ape_safe/client/__init__.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from collections.abc import Iterator
33
from datetime import datetime
44
from functools import reduce
5-
from typing import Optional, Union, cast
5+
from typing import TYPE_CHECKING, Optional, Union, cast
66

77
from ape.types import AddressType, HexBytes, MessageSignature
88
from ape.utils import USER_AGENT, get_package_version
@@ -12,6 +12,7 @@
1212
from ape_safe.client.base import BaseSafeClient
1313
from ape_safe.client.mock import MockSafeClient
1414
from ape_safe.client.types import (
15+
DelegateInfo,
1516
ExecutedTxData,
1617
OperationType,
1718
SafeApiTxData,
@@ -23,12 +24,16 @@
2324
UnexecutedTxData,
2425
)
2526
from ape_safe.exceptions import (
27+
ActionNotPerformedError,
2628
ClientResponseError,
2729
ClientUnsupportedChainError,
2830
MultisigTransactionNotFoundError,
2931
)
3032
from ape_safe.utils import get_safe_tx_hash, order_by_signer
3133

34+
if TYPE_CHECKING:
35+
from ape.api import AccountAPI
36+
3237
APE_SAFE_VERSION = get_package_version(__name__)
3338
APE_SAFE_USER_AGENT = f"Ape-Safe/{APE_SAFE_VERSION} {USER_AGENT}"
3439
# NOTE: Origin must be a string, but can be json that contains url & name fields
@@ -80,7 +85,7 @@ def __init__(
8085

8186
@property
8287
def safe_details(self) -> SafeDetails:
83-
response = self._get(f"safes/{self.address}")
88+
response = self._get(f"safes/{self.address}/")
8489
return SafeDetails.model_validate(response.json())
8590

8691
def get_next_nonce(self) -> int:
@@ -91,7 +96,7 @@ def _all_transactions(self) -> Iterator[SafeApiTxData]:
9196
Get all transactions from safe, both confirmed and unconfirmed
9297
"""
9398

94-
url = f"safes/{self.address}/all-transactions"
99+
url = f"safes/{self.address}/all-transactions/"
95100
while url:
96101
response = self._get(url)
97102
data = response.json()
@@ -110,7 +115,7 @@ def _all_transactions(self) -> Iterator[SafeApiTxData]:
110115
url = data.get("next")
111116

112117
def get_confirmations(self, safe_tx_hash: SafeTxID) -> Iterator[SafeTxConfirmation]:
113-
url = f"multisig-transactions/{str(safe_tx_hash)}/confirmations"
118+
url = f"multisig-transactions/{str(safe_tx_hash)}/confirmations/"
114119
while url:
115120
response = self._get(url)
116121
data = response.json()
@@ -129,7 +134,7 @@ def post_transaction(
129134
b"",
130135
)
131136
)
132-
post_dict: dict = {"signature": to_hex(signature), "origin": ORIGIN}
137+
post_dict: dict = {"signature": signature.hex() if signature else None, "origin": ORIGIN}
133138

134139
for key, value in tx_data.model_dump(by_alias=True, mode="json").items():
135140
if isinstance(value, HexBytes):
@@ -148,7 +153,7 @@ def post_transaction(
148153
# Signature handled above.
149154
post_dict.pop("signatures")
150155

151-
url = f"safes/{tx_data.safe}/multisig-transactions"
156+
url = f"safes/{tx_data.safe}/multisig-transactions/"
152157
response = self._post(url, json=post_dict)
153158
return response
154159

@@ -164,7 +169,7 @@ def post_signatures(
164169
safe_tx_hash = safe_tx_or_hash
165170

166171
safe_tx_hash = cast(SafeTxID, to_hex(HexBytes(safe_tx_hash)))
167-
url = f"multisig-transactions/{safe_tx_hash}/confirmations"
172+
url = f"multisig-transactions/{safe_tx_hash}/confirmations/"
168173
signature = to_hex(
169174
HexBytes(b"".join([x.encode_rsv() for x in order_by_signer(signatures)]))
170175
)
@@ -179,7 +184,7 @@ def post_signatures(
179184
def estimate_gas_cost(
180185
self, receiver: AddressType, value: int, data: bytes, operation: int = 0
181186
) -> Optional[int]:
182-
url = f"safes/{self.address}/multisig-transactions/estimations"
187+
url = f"safes/{self.address}/multisig-transactions/estimations/"
183188
request: dict = {
184189
"to": receiver,
185190
"value": value,
@@ -190,6 +195,53 @@ def estimate_gas_cost(
190195
gas = result.get("safeTxGas")
191196
return int(to_hex(HexBytes(gas)), 16)
192197

198+
def get_delegates(self) -> dict["AddressType", list["AddressType"]]:
199+
url = "delegates/"
200+
delegates: dict[AddressType, list[AddressType]] = {}
201+
202+
while url:
203+
response = self._get(url, params={"safe": self.address})
204+
data = response.json()
205+
206+
for delegate_info in map(DelegateInfo.model_validate, data.get("results", [])):
207+
if delegate_info.delegator not in delegates:
208+
delegates[delegate_info.delegator] = [delegate_info.delegate]
209+
else:
210+
delegates[delegate_info.delegator].append(delegate_info.delegate)
211+
212+
url = data.get("next")
213+
214+
return delegates
215+
216+
def add_delegate(self, delegate: "AddressType", label: str, delegator: "AccountAPI"):
217+
msg_hash = self.create_delegate_message(delegate)
218+
219+
# NOTE: This is required as Safe API uses an antiquated .signHash method
220+
if not (sig := delegator.sign_raw_msghash(msg_hash)):
221+
raise ActionNotPerformedError("Did not sign delegate approval")
222+
223+
payload = {
224+
"safe": self.address,
225+
"delegate": delegate,
226+
"delegator": delegator.address,
227+
"label": label,
228+
"signature": sig.encode_rsv().hex(),
229+
}
230+
self._post("delegates/", json=payload)
231+
232+
def remove_delegate(self, delegate: "AddressType", delegator: "AccountAPI"):
233+
msg_hash = self.create_delegate_message(delegate)
234+
235+
# NOTE: This is required as Safe API uses an antiquated .signHash method
236+
if not (sig := delegator.sign_raw_msghash(msg_hash)):
237+
raise ActionNotPerformedError("Did not sign delegate removal")
238+
239+
payload = {
240+
"delegator": delegator.address,
241+
"signature": sig.encode_rsv().hex(),
242+
}
243+
self._delete(f"delegates/{delegate}/", json=payload)
244+
193245

194246
__all__ = [
195247
"ExecutedTxData",

0 commit comments

Comments
 (0)