Skip to content

Commit 50fa523

Browse files
bantegfubuloubu
andauthored
feat: refactor safe client to use multichain gateway (#74)
* fix: remove blast url because it's not officially supported * feat(client): refactor safe client to use multichain gateway * refactor(Client): standardize around pre-pending forward slash paths * fix(Client): gateway uses `/multisig-transactions/raw` path now * fix(Client): gateway API uses complex object for addresses now * fix(Client): set default to None for optional fields * refactor(Client): remove unused exception type * fix(Client): new field is `implementation` not `masterCopy` * refactor(Account): typing issues * refactor(Client): fix other API endpoints to use gateway * fix(Account): typing issue * refactor(Client): use the _new_ new Safe multichain API tx-service * refactor(Account): use `.account_data` instead of `.account_file` * tests(Client): refactor client tests * fix(CI): run w/ API key --------- Co-authored-by: fubuloubu <3859395+fubuloubu@users.noreply.github.com>
1 parent 907bb11 commit 50fa523

8 files changed

Lines changed: 135 additions & 105 deletions

File tree

.github/workflows/test.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,11 @@ jobs:
8989
pip install .[test]
9090
9191
- name: Run Functional Tests
92+
env:
93+
APE_SAFE_GATEWAY_API_KEY: ${{ secrets.APE_SAFE_GATEWAY_API_KEY }}
9294
run: ape test -n 0 tests/functional/ -s --cov -v WARNING
9395

9496
- name: Run Integration Tests
97+
env:
98+
APE_SAFE_GATEWAY_API_KEY: ${{ secrets.APE_SAFE_GATEWAY_API_KEY }}
9599
run: ape test -n 0 tests/integration/ -s --cov -v WARNING

ape_safe/_cli/safe_mgmt.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from eth_typing import ChecksumAddress
1111

1212
from ape_safe._cli.click_ext import SafeCliContext, safe_argument, safe_cli_ctx
13+
from ape_safe.client import SafeClient
1314

1415

1516
@click.command(name="list")
@@ -145,9 +146,8 @@ def all_txns(cli_ctx: SafeCliContext, account, confirmed):
145146
account = cli_ctx.account_manager.load(account)
146147

147148
address = cli_ctx.conversion_manager.convert(account, AddressType)
148-
149-
# NOTE: Create a client to support non-local safes.
150-
client = cli_ctx.safes.create_client(address)
149+
chain_id = cli_ctx.provider.chain_id
150+
client = SafeClient(address=address, chain_id=chain_id)
151151

152152
for txn in client.get_transactions(confirmed=confirmed):
153153
if isinstance(txn, ExecutedTxData):

ape_safe/accounts.py

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -169,27 +169,6 @@ def delete_account(self, alias: str):
169169
"""
170170
self._get_path(alias).unlink(missing_ok=True)
171171

172-
def create_client(self, key: str) -> BaseSafeClient:
173-
if key in self.aliases:
174-
safe = self.load_account(key)
175-
return safe.client
176-
177-
elif key in self.addresses:
178-
account = cast(SafeAccount, self[cast(AddressType, key)])
179-
return account.client
180-
181-
elif key in self.aliases:
182-
return self.load_account(key).client
183-
184-
else:
185-
address = self.conversion_manager.convert(key, AddressType)
186-
if address in self.addresses:
187-
account = cast(SafeAccount, self[cast(AddressType, key)])
188-
return account.client
189-
190-
# Is not locally managed.
191-
return SafeClient(address=address, chain_id=self.chain_manager.provider.chain_id)
192-
193172
def _get_path(self, alias: str) -> Path:
194173
return self.data_folder.joinpath(f"{alias}.json")
195174

@@ -220,17 +199,21 @@ def alias(self) -> str:
220199
return self.account_file_path.stem
221200

222201
@property
223-
def account_file(self) -> SafeCacheData:
202+
def account_data(self) -> SafeCacheData:
224203
return SafeCacheData.model_validate_json(self.account_file_path.read_text(encoding="utf-8"))
225204

205+
@property
206+
def deployed_chain_ids(self) -> list[int]:
207+
return self.account_data.deployed_chain_ids
208+
226209
@cached_property
227210
def address(self) -> AddressType:
228211
try:
229212
ecosystem = self.provider.network.ecosystem
230213
except ProviderNotConnectedError:
231214
ecosystem = self.network_manager.ethereum
232215

233-
return ecosystem.decode_address(self.account_file.address)
216+
return ecosystem.decode_address(self.account_data.address)
234217

235218
@cached_property
236219
def contract(self) -> "ContractInstance":
@@ -288,34 +271,32 @@ def set_guard(
288271

289272
return self.contract.setGuard(new_guard, **tx_args)
290273

291-
@cached_property
292-
def client(self) -> BaseSafeClient:
293-
chain_id = self.provider.chain_id
294-
override_url = os.environ.get("SAFE_TRANSACTION_SERVICE_URL")
274+
def get_client(
275+
self, chain_id: Optional[int] = None, override_url: Optional[str] = None
276+
) -> BaseSafeClient:
277+
if chain_id is None:
278+
chain_id = self.provider.chain_id
279+
280+
if override_url is None:
281+
env_override = os.environ.get("SAFE_TRANSACTION_SERVICE_URL")
282+
if env_override:
283+
override_url = env_override
295284

296-
if self.provider.network.is_local:
285+
if chain_id == 0 or (self.provider.network.is_local and self.provider.chain_id == chain_id):
297286
return MockSafeClient(contract=self.contract)
298287

299-
elif chain_id in self.account_file.deployed_chain_ids:
300-
return SafeClient(
301-
address=self.address, chain_id=self.provider.chain_id, override_url=override_url
302-
)
288+
return SafeClient(address=self.address, chain_id=chain_id, override_url=override_url)
303289

304-
elif (
290+
@cached_property
291+
def client(self) -> BaseSafeClient:
292+
if (
305293
self.provider.network.name.endswith("-fork")
306294
and isinstance(self.provider.network, ForkedNetworkAPI)
307-
and self.provider.network.upstream_chain_id in self.account_file.deployed_chain_ids
295+
and self.provider.network.upstream_chain_id in self.deployed_chain_ids
308296
):
309-
return SafeClient(
310-
address=self.address,
311-
chain_id=self.provider.network.upstream_chain_id,
312-
override_url=override_url,
313-
)
314-
315-
elif self.provider.network.is_dev:
316-
return MockSafeClient(contract=self.contract)
297+
return self.get_client(chain_id=self.provider.network.upstream_chain_id)
317298

318-
return SafeClient(address=self.address, chain_id=self.provider.chain_id)
299+
return self.get_client()
319300

320301
@property
321302
def version(self) -> Version:
@@ -444,6 +425,7 @@ def propose_safe_tx(
444425

445426
if submitter is not None and not isinstance(submitter, AccountAPI):
446427
submitter = self.load_submitter(submitter)
428+
assert isinstance(submitter, AccountAPI) # NOTE: mypy happy
447429

448430
if (
449431
submitter is not None
@@ -701,6 +683,7 @@ def submit_safe_tx(
701683

702684
if not isinstance(submitter, AccountAPI):
703685
submitter = self.load_submitter(submitter)
686+
assert isinstance(submitter, AccountAPI) # NOTE: mypy happy
704687

705688
return submitter.call(txn)
706689

ape_safe/client/__init__.py

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
from collections.abc import Iterator
34
from datetime import datetime
45
from functools import reduce
@@ -26,37 +27,45 @@
2627
from ape_safe.exceptions import (
2728
ActionNotPerformedError,
2829
ClientResponseError,
29-
ClientUnsupportedChainError,
3030
MultisigTransactionNotFoundError,
3131
)
3232
from ape_safe.utils import get_safe_tx_hash, order_by_signer
3333

3434
if TYPE_CHECKING:
3535
from ape.api import AccountAPI
36+
from requests import Response
3637

3738
APE_SAFE_VERSION = get_package_version(__name__)
3839
APE_SAFE_USER_AGENT = f"Ape-Safe/{APE_SAFE_VERSION} {USER_AGENT}"
3940
# NOTE: Origin must be a string, but can be json that contains url & name fields
4041
ORIGIN = json.dumps(dict(url="https://apeworx.io", name="Ape Safe", ua=APE_SAFE_USER_AGENT))
4142
assert len(ORIGIN) <= 200 # NOTE: Must be less than 200 chars
4243

43-
TRANSACTION_SERVICE_URL = {
44-
# NOTE: If URLs need to be updated, a list of available service URLs can be found at
45-
# https://docs.safe.global/safe-core-api/available-services.
46-
# NOTE: There should be no trailing slashes at the end of the URL.
47-
1: "https://safe-transaction-mainnet.safe.global",
48-
10: "https://safe-transaction-optimism.safe.global",
49-
56: "https://safe-transaction-bsc.safe.global",
50-
100: "https://safe-transaction-gnosis-chain.safe.global",
51-
137: "https://safe-transaction-polygon.safe.global",
52-
250: "https://safe-txservice.fantom.network",
53-
288: "https://safe-transaction.mainnet.boba.network",
54-
8453: "https://safe-transaction-base.safe.global",
55-
42161: "https://safe-transaction-arbitrum.safe.global",
56-
43114: "https://safe-transaction-avalanche.safe.global",
57-
84531: "https://safe-transaction-base-testnet.safe.global",
58-
11155111: "https://safe-transaction-sepolia.safe.global",
59-
81457: "https://transaction.blast-safe.io",
44+
# URL for the multichain client gateway
45+
SAFE_CLIENT_GATEWAY_URL = "https://api.safe.global/tx-service"
46+
GATEWAY_API_KEY = os.environ.get("APE_SAFE_GATEWAY_API_KEY")
47+
EIP3770_BLOCKCHAIN_NAMES_BY_CHAIN_ID = {
48+
1: "eth", # Ethereum Mainnet
49+
11155111: "sep", # Ethereum Sepolia
50+
10: "oeth", # Optimism Mainnet
51+
42161: "arb1", # Arbitrum One Mainnet
52+
56: "bnb", # Binance Smart Chain
53+
146: "sonic",
54+
5000: "mantle",
55+
43114: "avax",
56+
1313161554: "aurora",
57+
8453: "base", # Base Mainnet
58+
84532: "basesep", # Base Sepolia
59+
42220: "celo", # Celo Mainnet
60+
100: "gno", # Gnosis Chain
61+
59144: "linea", # Linea Mainnet
62+
137: "pol", # Polygon
63+
534352: "scr", # Scroll
64+
130: "unichain", # Unichain Mainnet
65+
480: "wc", # Worldchain Mainnet
66+
324: "zksync", # zkSync Mainnet
67+
57073: "ink", # Ink Mainnet
68+
800094: "berachain", # Berachain Mainnet
6069
}
6170

6271

@@ -68,24 +77,34 @@ def __init__(
6877
chain_id: Optional[int] = None,
6978
) -> None:
7079
self.address = address
80+
self.chain_id = chain_id
7181

7282
if override_url:
73-
tx_service_url = override_url
74-
83+
base_url = override_url
7584
elif chain_id:
76-
if chain_id not in TRANSACTION_SERVICE_URL:
77-
raise ClientUnsupportedChainError(chain_id)
85+
if chain_id not in EIP3770_BLOCKCHAIN_NAMES_BY_CHAIN_ID:
86+
raise ValueError(f"Chain ID {chain_id} is not a supported chain.")
7887

79-
tx_service_url = TRANSACTION_SERVICE_URL[chain_id]
88+
elif not GATEWAY_API_KEY:
89+
raise ValueError("Must provide API key via 'APE_SAFE_GATEWAY_API_KEY='.")
8090

91+
base_url = (
92+
f"{SAFE_CLIENT_GATEWAY_URL}/{EIP3770_BLOCKCHAIN_NAMES_BY_CHAIN_ID[chain_id]}/api"
93+
)
8194
else:
8295
raise ValueError("Must provide one of chain_id or override_url.")
8396

84-
super().__init__(tx_service_url)
97+
super().__init__(base_url)
98+
99+
def _request(self, method: str, url: str, json: Optional[dict] = None, **kwargs) -> "Response":
100+
# NOTE: Add authorization header
101+
headers = kwargs.pop("headers", {})
102+
headers.update(dict(Authorization=f"Bearer {GATEWAY_API_KEY}"))
103+
return super()._request(method, url, json=json, headers=headers, **kwargs)
85104

86105
@property
87106
def safe_details(self) -> SafeDetails:
88-
response = self._get(f"safes/{self.address}/")
107+
response = self._get(f"/safes/{self.address}")
89108
return SafeDetails.model_validate(response.json())
90109

91110
def get_next_nonce(self) -> int:
@@ -96,7 +115,7 @@ def _all_transactions(self) -> Iterator[SafeApiTxData]:
96115
Get all transactions from safe, both confirmed and unconfirmed
97116
"""
98117

99-
url = f"safes/{self.address}/all-transactions/"
118+
url = f"/safes/{self.address}/multisig-transactions"
100119
while url:
101120
response = self._get(url)
102121
data = response.json()
@@ -115,12 +134,9 @@ def _all_transactions(self) -> Iterator[SafeApiTxData]:
115134
url = data.get("next")
116135

117136
def get_confirmations(self, safe_tx_hash: SafeTxID) -> Iterator[SafeTxConfirmation]:
118-
url = f"multisig-transactions/{str(safe_tx_hash)}/confirmations/"
119-
while url:
120-
response = self._get(url)
121-
data = response.json()
122-
yield from map(SafeTxConfirmation.model_validate, data.get("results"))
123-
url = data.get("next")
137+
response = self._get(f"/multisig-transactions/{safe_tx_hash}/raw")
138+
data = response.json()
139+
yield from map(SafeTxConfirmation.model_validate, data.get("confirmations", []))
124140

125141
def post_transaction(
126142
self, safe_tx: SafeTx, signatures: dict[AddressType, MessageSignature], **kwargs
@@ -153,7 +169,7 @@ def post_transaction(
153169
# Signature handled above.
154170
post_dict.pop("signatures")
155171

156-
url = f"safes/{tx_data.safe}/multisig-transactions/"
172+
url = f"/transactions/{tx_data.safe}/propose"
157173
response = self._post(url, json=post_dict)
158174
return response
159175

@@ -169,7 +185,7 @@ def post_signatures(
169185
safe_tx_hash = safe_tx_or_hash
170186

171187
safe_tx_hash = cast(SafeTxID, to_hex(HexBytes(safe_tx_hash)))
172-
url = f"multisig-transactions/{safe_tx_hash}/confirmations/"
188+
url = f"/transactions/{safe_tx_hash}/confirmations"
173189
signature = to_hex(
174190
HexBytes(b"".join([x.encode_rsv() for x in order_by_signer(signatures)]))
175191
)
@@ -184,7 +200,7 @@ def post_signatures(
184200
def estimate_gas_cost(
185201
self, receiver: AddressType, value: int, data: bytes, operation: int = 0
186202
) -> Optional[int]:
187-
url = f"safes/{self.address}/multisig-transactions/estimations/"
203+
url = f"/safes/{self.address}/multisig-transactions/estimations"
188204
request: dict = {
189205
"to": receiver,
190206
"value": value,
@@ -196,7 +212,7 @@ def estimate_gas_cost(
196212
return int(to_hex(HexBytes(gas)), 16)
197213

198214
def get_delegates(self) -> dict["AddressType", list["AddressType"]]:
199-
url = "delegates/"
215+
url = "/delegates"
200216
delegates: dict[AddressType, list[AddressType]] = {}
201217

202218
while url:
@@ -227,7 +243,7 @@ def add_delegate(self, delegate: "AddressType", label: str, delegator: "AccountA
227243
"label": label,
228244
"signature": sig.encode_rsv().hex(),
229245
}
230-
self._post("delegates/", json=payload)
246+
self._post("/delegates", json=payload)
231247

232248
def remove_delegate(self, delegate: "AddressType", delegator: "AccountAPI"):
233249
msg_hash = self.create_delegate_message(delegate)
@@ -240,7 +256,7 @@ def remove_delegate(self, delegate: "AddressType", delegator: "AccountAPI"):
240256
"delegator": delegator.address,
241257
"signature": sig.encode_rsv().hex(),
242258
}
243-
self._delete(f"delegates/{delegate}/", json=payload)
259+
self._delete(f"/delegates/{delegate}", json=payload)
244260

245261

246262
__all__ = [

ape_safe/client/base.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333

3434

3535
class BaseSafeClient(ABC):
36-
def __init__(self, transaction_service_url: str):
37-
self.transaction_service_url = transaction_service_url
36+
def __init__(self, base_url: str):
37+
self.base_url = base_url
3838

3939
"""Abstract methods"""
4040

@@ -160,13 +160,15 @@ def _http(self):
160160
return urllib3.PoolManager(ca_certs=certifi.where())
161161

162162
def _request(self, method: str, url: str, json: Optional[dict] = None, **kwargs) -> "Response":
163+
api_version = kwargs.pop("api_version", "v1")
164+
163165
# NOTE: paged requests include full url already
164-
if url.startswith(f"{self.transaction_service_url}/api/v1/"):
166+
if url.startswith(self.base_url):
165167
api_url = url
168+
166169
else:
167-
# **WARNING**: The trailing slash in the URL is CRITICAL!
168-
# If you remove it, things will not work as expected.
169-
api_url = f"{self.transaction_service_url}/api/v1/{url}/"
170+
api_url = f"{self.base_url}/{api_version}{url}"
171+
170172
do_fail = not kwargs.pop("allow_failure", False)
171173

172174
# Use `or 10` to handle when None is explicit.

0 commit comments

Comments
 (0)