safe-kit 0.0.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
safe_kit/service.py ADDED
@@ -0,0 +1,343 @@
1
+ from typing import Any, cast
2
+
3
+ import requests
4
+
5
+ from safe_kit.errors import SafeServiceError
6
+ from safe_kit.types import (
7
+ SafeBalanceResponse,
8
+ SafeCollectibleResponse,
9
+ SafeCreationInfoResponse,
10
+ SafeDataDecoderResponse,
11
+ SafeDelegateResponse,
12
+ SafeIncomingTransactionResponse,
13
+ SafeInfoResponse,
14
+ SafeModuleTransactionResponse,
15
+ SafeMultisigTransactionResponse,
16
+ SafeServiceInfo,
17
+ SafeTokenResponse,
18
+ SafeTransactionData,
19
+ )
20
+
21
+
22
+ class SafeServiceClient:
23
+ """
24
+ Client for interacting with the Safe Transaction Service API.
25
+ """
26
+
27
+ def __init__(self, service_url: str):
28
+ self.service_url = service_url.rstrip("/")
29
+
30
+ def _handle_response(self, response: requests.Response) -> Any:
31
+ try:
32
+ response.raise_for_status()
33
+ if response.content:
34
+ return response.json()
35
+ return None
36
+ except requests.HTTPError as e:
37
+ raise SafeServiceError(f"Service error: {e}", response.status_code) from e
38
+ except Exception as e:
39
+ raise SafeServiceError(f"Unexpected error: {e}") from e
40
+
41
+ def get_service_info(self) -> SafeServiceInfo:
42
+ """
43
+ Returns information about the Safe Transaction Service.
44
+ """
45
+ response = requests.get(f"{self.service_url}/v1/about/")
46
+ data = self._handle_response(response)
47
+ return SafeServiceInfo(**data)
48
+
49
+ def propose_transaction(
50
+ self,
51
+ safe_address: str,
52
+ safe_tx_data: SafeTransactionData,
53
+ safe_tx_hash: str,
54
+ sender_address: str,
55
+ signature: str,
56
+ origin: str | None = None,
57
+ ) -> None:
58
+ """
59
+ Proposes a transaction to the Safe Transaction Service.
60
+ """
61
+ url = f"{self.service_url}/v1/safes/{safe_address}/multisig-transactions/"
62
+
63
+ payload = {
64
+ "to": safe_tx_data.to,
65
+ "value": safe_tx_data.value,
66
+ "data": safe_tx_data.data if safe_tx_data.data else None,
67
+ "operation": safe_tx_data.operation,
68
+ "safeTxGas": safe_tx_data.safe_tx_gas,
69
+ "baseGas": safe_tx_data.base_gas,
70
+ "gasPrice": safe_tx_data.gas_price,
71
+ "gasToken": safe_tx_data.gas_token,
72
+ "refundReceiver": safe_tx_data.refund_receiver,
73
+ "nonce": safe_tx_data.nonce,
74
+ "contractTransactionHash": safe_tx_hash,
75
+ "sender": sender_address,
76
+ "signature": signature,
77
+ "origin": origin,
78
+ }
79
+
80
+ response = requests.post(url, json=payload)
81
+ self._handle_response(response)
82
+
83
+ def get_pending_transactions(
84
+ self, safe_address: str, current_nonce: int | None = None
85
+ ) -> list[SafeMultisigTransactionResponse]:
86
+ """
87
+ Returns the list of pending transactions for a Safe.
88
+ """
89
+ return self.get_multisig_transactions(
90
+ safe_address,
91
+ executed=False,
92
+ trust=True,
93
+ nonce_gte=current_nonce,
94
+ )
95
+
96
+ def get_multisig_transactions(
97
+ self,
98
+ safe_address: str,
99
+ executed: bool | None = None,
100
+ trust: bool | None = None,
101
+ nonce_gte: int | None = None,
102
+ ordering: str | None = None,
103
+ limit: int | None = None,
104
+ offset: int | None = None,
105
+ ) -> list[SafeMultisigTransactionResponse]:
106
+ """
107
+ Returns the list of multisig transactions for a Safe.
108
+ """
109
+ url = f"{self.service_url}/v1/safes/{safe_address}/multisig-transactions/"
110
+ params: dict[str, Any] = {}
111
+
112
+ if executed is not None:
113
+ params["executed"] = str(executed).lower()
114
+ if trust is not None:
115
+ params["trusted"] = str(trust).lower()
116
+ if nonce_gte is not None:
117
+ params["nonce__gte"] = str(nonce_gte)
118
+ if ordering:
119
+ params["ordering"] = ordering
120
+ if limit is not None:
121
+ params["limit"] = str(limit)
122
+ if offset is not None:
123
+ params["offset"] = str(offset)
124
+
125
+ response = requests.get(url, params=params)
126
+ data = self._handle_response(response)
127
+ results = data.get("results", [])
128
+ return [SafeMultisigTransactionResponse(**tx) for tx in results]
129
+
130
+ def confirm_transaction(self, safe_tx_hash: str, signature: str) -> None:
131
+ """
132
+ Adds a confirmation (signature) to a pending transaction.
133
+ """
134
+ url = (
135
+ f"{self.service_url}/v1/multisig-transactions/{safe_tx_hash}/confirmations/"
136
+ )
137
+ payload = {"signature": signature}
138
+
139
+ response = requests.post(url, json=payload)
140
+ self._handle_response(response)
141
+
142
+ def delete_transaction(self, safe_tx_hash: str, signature: str) -> None:
143
+ """
144
+ Deletes a pending transaction from the Safe Transaction Service.
145
+ Requires the signature of the proposer.
146
+ """
147
+ url = f"{self.service_url}/v1/multisig-transactions/{safe_tx_hash}/"
148
+ payload = {"signature": signature}
149
+ response = requests.delete(url, json=payload)
150
+ self._handle_response(response)
151
+
152
+ def get_transaction(self, safe_tx_hash: str) -> SafeMultisigTransactionResponse:
153
+ """
154
+ Returns the details of a specific Safe transaction.
155
+ """
156
+ url = f"{self.service_url}/v1/multisig-transactions/{safe_tx_hash}/"
157
+ response = requests.get(url)
158
+ data = self._handle_response(response)
159
+ return SafeMultisigTransactionResponse(**data)
160
+
161
+ def get_safes_by_owner(self, owner_address: str) -> list[str]:
162
+ """
163
+ Returns the list of Safes owned by an address.
164
+ """
165
+ url = f"{self.service_url}/v1/owners/{owner_address}/safes/"
166
+ response = requests.get(url)
167
+ data = self._handle_response(response)
168
+ return cast(list[str], data.get("safes", []))
169
+
170
+ def get_balances(
171
+ self, safe_address: str, trusted: bool = False, exclude_spam: bool = True
172
+ ) -> list[SafeBalanceResponse]:
173
+ """
174
+ Returns the balances of a Safe (ETH and ERC20).
175
+ """
176
+ url = f"{self.service_url}/v1/safes/{safe_address}/balances/"
177
+ params = {
178
+ "trusted": str(trusted).lower(),
179
+ "exclude_spam": str(exclude_spam).lower(),
180
+ }
181
+ response = requests.get(url, params=params)
182
+ data = self._handle_response(response)
183
+ return [SafeBalanceResponse(**item) for item in data]
184
+
185
+ def get_incoming_transactions(
186
+ self,
187
+ safe_address: str,
188
+ executed: bool | None = None,
189
+ limit: int | None = None,
190
+ offset: int | None = None,
191
+ ) -> list[SafeIncomingTransactionResponse]:
192
+ """
193
+ Returns the incoming transactions (ETH/ERC20) for a Safe.
194
+ """
195
+ url = f"{self.service_url}/v1/safes/{safe_address}/incoming-transfers/"
196
+ params: dict[str, Any] = {}
197
+ if executed is not None:
198
+ params["executed"] = str(executed).lower()
199
+ if limit is not None:
200
+ params["limit"] = str(limit)
201
+ if offset is not None:
202
+ params["offset"] = str(offset)
203
+
204
+ response = requests.get(url, params=params)
205
+ data = self._handle_response(response)
206
+ results = data.get("results", [])
207
+ return [SafeIncomingTransactionResponse(**tx) for tx in results]
208
+
209
+ def get_module_transactions(
210
+ self,
211
+ safe_address: str,
212
+ limit: int | None = None,
213
+ offset: int | None = None,
214
+ ) -> list[SafeModuleTransactionResponse]:
215
+ """
216
+ Returns the module transactions for a Safe.
217
+ """
218
+ url = f"{self.service_url}/v1/safes/{safe_address}/module-transactions/"
219
+ params: dict[str, Any] = {}
220
+ if limit is not None:
221
+ params["limit"] = str(limit)
222
+ if offset is not None:
223
+ params["offset"] = str(offset)
224
+
225
+ response = requests.get(url, params=params)
226
+ data = self._handle_response(response)
227
+ results = data.get("results", [])
228
+ return [SafeModuleTransactionResponse(**tx) for tx in results]
229
+
230
+ def get_safe_info(self, safe_address: str) -> SafeInfoResponse:
231
+ """
232
+ Returns detailed information about a Safe.
233
+ """
234
+ url = f"{self.service_url}/v1/safes/{safe_address}/"
235
+ response = requests.get(url)
236
+ data = self._handle_response(response)
237
+ return SafeInfoResponse(**data)
238
+
239
+ def get_creation_info(self, safe_address: str) -> SafeCreationInfoResponse:
240
+ """
241
+ Returns information about when and how a Safe was created.
242
+ """
243
+ url = f"{self.service_url}/v1/safes/{safe_address}/creation/"
244
+ response = requests.get(url)
245
+ data = self._handle_response(response)
246
+ return SafeCreationInfoResponse(**data)
247
+
248
+ def get_collectibles(
249
+ self,
250
+ safe_address: str,
251
+ trusted: bool = False,
252
+ exclude_spam: bool = True,
253
+ ) -> list[SafeCollectibleResponse]:
254
+ """
255
+ Returns NFTs (ERC721) owned by the Safe.
256
+ """
257
+ url = f"{self.service_url}/v1/safes/{safe_address}/collectibles/"
258
+ params = {
259
+ "trusted": str(trusted).lower(),
260
+ "exclude_spam": str(exclude_spam).lower(),
261
+ }
262
+ response = requests.get(url, params=params)
263
+ data = self._handle_response(response)
264
+ return [SafeCollectibleResponse(**item) for item in data]
265
+
266
+ def get_delegates(self, safe_address: str) -> list[SafeDelegateResponse]:
267
+ """
268
+ Returns the list of delegates for a Safe.
269
+ """
270
+ url = f"{self.service_url}/v1/delegates/"
271
+ params = {"safe": safe_address}
272
+ response = requests.get(url, params=params)
273
+ data = self._handle_response(response)
274
+ results = data.get("results", [])
275
+ return [SafeDelegateResponse(**item) for item in results]
276
+
277
+ def add_delegate(
278
+ self,
279
+ safe_address: str,
280
+ delegate_address: str,
281
+ delegator: str,
282
+ label: str,
283
+ signature: str,
284
+ ) -> None:
285
+ """
286
+ Adds a delegate to a Safe.
287
+ """
288
+ url = f"{self.service_url}/v1/delegates/"
289
+ payload = {
290
+ "safe": safe_address,
291
+ "delegate": delegate_address,
292
+ "delegator": delegator,
293
+ "label": label,
294
+ "signature": signature,
295
+ }
296
+ response = requests.post(url, json=payload)
297
+ self._handle_response(response)
298
+
299
+ def remove_delegate(
300
+ self,
301
+ delegate_address: str,
302
+ delegator: str,
303
+ signature: str,
304
+ ) -> None:
305
+ """
306
+ Removes a delegate.
307
+ """
308
+ url = f"{self.service_url}/v1/delegates/{delegate_address}/"
309
+ payload = {
310
+ "delegator": delegator,
311
+ "signature": signature,
312
+ }
313
+ response = requests.delete(url, json=payload)
314
+ self._handle_response(response)
315
+
316
+ def get_tokens(self) -> list[SafeTokenResponse]:
317
+ """
318
+ Returns the list of ERC20 tokens supported by the Safe Transaction Service.
319
+ """
320
+ url = f"{self.service_url}/v1/tokens/"
321
+ response = requests.get(url)
322
+ data = self._handle_response(response)
323
+ results = data.get("results", [])
324
+ return [SafeTokenResponse(**item) for item in results]
325
+
326
+ def get_token(self, token_address: str) -> SafeTokenResponse:
327
+ """
328
+ Returns information about a specific token.
329
+ """
330
+ url = f"{self.service_url}/v1/tokens/{token_address}/"
331
+ response = requests.get(url)
332
+ data = self._handle_response(response)
333
+ return SafeTokenResponse(**data)
334
+
335
+ def decode_data(self, data: str) -> SafeDataDecoderResponse:
336
+ """
337
+ Decodes transaction data using the Safe Transaction Service.
338
+ """
339
+ url = f"{self.service_url}/v1/data-decoder/"
340
+ payload = {"data": data}
341
+ response = requests.post(url, json=payload)
342
+ data_json = self._handle_response(response)
343
+ return SafeDataDecoderResponse(**data_json)
safe_kit/types.py ADDED
@@ -0,0 +1,239 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class SafeAccountConfig(BaseModel):
7
+ """
8
+ Configuration for deploying a new Safe.
9
+ """
10
+
11
+ owners: list[str]
12
+ threshold: int
13
+ to: str = "0x0000000000000000000000000000000000000000"
14
+ data: str = "0x"
15
+ fallback_handler: str = "0x0000000000000000000000000000000000000000"
16
+ payment_token: str = "0x0000000000000000000000000000000000000000"
17
+ payment: int = 0
18
+ payment_receiver: str = "0x0000000000000000000000000000000000000000"
19
+
20
+
21
+ class SafeTransactionData(BaseModel):
22
+ """
23
+ Model representing the data of a Safe transaction.
24
+ """
25
+
26
+ to: str
27
+ value: int
28
+ data: str
29
+ operation: int = 0
30
+ safe_tx_gas: int = 0
31
+ base_gas: int = 0
32
+ gas_price: int = 0
33
+ gas_token: str = "0x0000000000000000000000000000000000000000"
34
+ refund_receiver: str = "0x0000000000000000000000000000000000000000"
35
+ nonce: int | None = None
36
+
37
+ model_config = ConfigDict(arbitrary_types_allowed=True)
38
+
39
+ def get_eip712_data(self, chain_id: int, safe_address: str) -> dict[str, Any]:
40
+ from hexbytes import HexBytes
41
+
42
+ return {
43
+ "types": {
44
+ "EIP712Domain": [
45
+ {"name": "chainId", "type": "uint256"},
46
+ {"name": "verifyingContract", "type": "address"},
47
+ ],
48
+ "SafeTx": [
49
+ {"name": "to", "type": "address"},
50
+ {"name": "value", "type": "uint256"},
51
+ {"name": "data", "type": "bytes"},
52
+ {"name": "operation", "type": "uint8"},
53
+ {"name": "safeTxGas", "type": "uint256"},
54
+ {"name": "baseGas", "type": "uint256"},
55
+ {"name": "gasPrice", "type": "uint256"},
56
+ {"name": "gasToken", "type": "address"},
57
+ {"name": "refundReceiver", "type": "address"},
58
+ {"name": "nonce", "type": "uint256"},
59
+ ],
60
+ },
61
+ "primaryType": "SafeTx",
62
+ "domain": {
63
+ "chainId": chain_id,
64
+ "verifyingContract": safe_address,
65
+ },
66
+ "message": {
67
+ "to": self.to,
68
+ "value": self.value,
69
+ "data": HexBytes(self.data),
70
+ "operation": self.operation,
71
+ "safeTxGas": self.safe_tx_gas,
72
+ "baseGas": self.base_gas,
73
+ "gasPrice": self.gas_price,
74
+ "gasToken": self.gas_token,
75
+ "refundReceiver": self.refund_receiver,
76
+ "nonce": self.nonce if self.nonce is not None else 0,
77
+ },
78
+ }
79
+
80
+
81
+ class SafeTransaction(BaseModel):
82
+ """
83
+ Model representing a complete Safe transaction including signatures.
84
+ """
85
+
86
+ data: SafeTransactionData
87
+ signatures: dict[str, str] = Field(default_factory=dict)
88
+
89
+ def add_signature(self, owner: str, signature: str) -> None:
90
+ self.signatures[owner] = signature
91
+
92
+ @property
93
+ def sorted_signatures_bytes(self) -> bytes:
94
+ from hexbytes import HexBytes
95
+
96
+ # Sort by owner address
97
+ sorted_owners = sorted(self.signatures.keys(), key=lambda x: int(x, 16))
98
+ signature_bytes = b""
99
+ for owner in sorted_owners:
100
+ signature_bytes += HexBytes(self.signatures[owner])
101
+ return signature_bytes
102
+
103
+
104
+ class SafeServiceInfo(BaseModel):
105
+ name: str
106
+ version: str
107
+ api_version: str
108
+ secure: bool
109
+ settings: dict[str, Any]
110
+
111
+
112
+ class SafeMultisigTransactionResponse(BaseModel):
113
+ safe: str
114
+ to: str
115
+ value: str
116
+ data: str | None
117
+ operation: int
118
+ gas_token: str = Field(alias="gasToken")
119
+ safe_tx_gas: int = Field(alias="safeTxGas")
120
+ base_gas: int = Field(alias="baseGas")
121
+ gas_price: str = Field(alias="gasPrice")
122
+ refund_receiver: str = Field(alias="refundReceiver")
123
+ nonce: int
124
+ execution_date: str | None = Field(alias="executionDate")
125
+ submission_date: str = Field(alias="submissionDate")
126
+ modified: str
127
+ block_number: int | None = Field(alias="blockNumber")
128
+ transaction_hash: str | None = Field(alias="transactionHash")
129
+ safe_tx_hash: str = Field(alias="safeTxHash")
130
+ executor: str | None
131
+ is_executed: bool = Field(alias="isExecuted")
132
+ is_successful: bool | None = Field(alias="isSuccessful")
133
+ eth_gas_price: str | None = Field(alias="ethGasPrice")
134
+ max_fee_per_gas: str | None = Field(alias="maxFeePerGas")
135
+ max_priority_fee_per_gas: str | None = Field(alias="maxPriorityFeePerGas")
136
+ gas_used: int | None = Field(alias="gasUsed")
137
+ fee: str | None
138
+ origin: str | None
139
+ data_decoded: dict[str, Any] | None = Field(alias="dataDecoded")
140
+ confirmations_required: int = Field(alias="confirmationsRequired")
141
+ confirmations: list[dict[str, Any]] | None
142
+ trusted: bool
143
+ signatures: str | None
144
+
145
+
146
+ class SafeBalanceResponse(BaseModel):
147
+ token_address: str | None = Field(alias="tokenAddress")
148
+ token: dict[str, Any] | None
149
+ balance: str
150
+
151
+
152
+ class SafeIncomingTransactionResponse(BaseModel):
153
+ execution_date: str = Field(alias="executionDate")
154
+ transaction_hash: str = Field(alias="transactionHash")
155
+ to: str
156
+ value: str
157
+ token_address: str | None = Field(alias="tokenAddress")
158
+ from_: str = Field(alias="from")
159
+
160
+
161
+ class SafeModuleTransactionResponse(BaseModel):
162
+ created: str
163
+ execution_date: str = Field(alias="executionDate")
164
+ block_number: int = Field(alias="blockNumber")
165
+ is_successful: bool = Field(alias="isSuccessful")
166
+ transaction_hash: str = Field(alias="transactionHash")
167
+ safe: str
168
+ module: str
169
+ to: str
170
+ value: str
171
+ data: str | None
172
+ operation: int
173
+ data_decoded: dict[str, Any] | None = Field(alias="dataDecoded")
174
+
175
+
176
+ class SafeInfoResponse(BaseModel):
177
+ """Information about a Safe from the Transaction Service."""
178
+
179
+ address: str
180
+ nonce: int
181
+ threshold: int
182
+ owners: list[str]
183
+ master_copy: str = Field(alias="masterCopy")
184
+ modules: list[str]
185
+ fallback_handler: str = Field(alias="fallbackHandler")
186
+ guard: str
187
+ version: str | None
188
+
189
+
190
+ class SafeCreationInfoResponse(BaseModel):
191
+ """Information about Safe creation."""
192
+
193
+ created: str
194
+ creator: str
195
+ transaction_hash: str = Field(alias="transactionHash")
196
+ factory_address: str = Field(alias="factoryAddress")
197
+ master_copy: str = Field(alias="masterCopy")
198
+ setup_data: str | None = Field(alias="setupData")
199
+
200
+
201
+ class SafeCollectibleResponse(BaseModel):
202
+ """NFT/Collectible owned by a Safe."""
203
+
204
+ address: str
205
+ token_name: str = Field(alias="tokenName")
206
+ token_symbol: str = Field(alias="tokenSymbol")
207
+ logo_uri: str = Field(alias="logoUri")
208
+ id: str
209
+ uri: str | None
210
+ name: str | None
211
+ description: str | None
212
+ image_uri: str | None = Field(alias="imageUri")
213
+ metadata: dict[str, Any] | None
214
+
215
+
216
+ class SafeDelegateResponse(BaseModel):
217
+ """Delegate for a Safe."""
218
+
219
+ safe: str | None
220
+ delegate: str
221
+ delegator: str
222
+ label: str
223
+
224
+
225
+ class SafeTokenResponse(BaseModel):
226
+ """Information about a Token."""
227
+
228
+ address: str
229
+ name: str
230
+ symbol: str
231
+ decimals: int
232
+ logo_uri: str | None = Field(alias="logoUri")
233
+
234
+
235
+ class SafeDataDecoderResponse(BaseModel):
236
+ """Decoded data from the Safe Transaction Service."""
237
+
238
+ method: str
239
+ parameters: list[dict[str, Any]] | None