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/__init__.py +22 -0
- safe_kit/abis.py +366 -0
- safe_kit/adapter.py +147 -0
- safe_kit/contract_types.py +151 -0
- safe_kit/errors.py +57 -0
- safe_kit/factory.py +143 -0
- safe_kit/managers/__init__.py +15 -0
- safe_kit/managers/guard_manager.py +61 -0
- safe_kit/managers/module_manager.py +85 -0
- safe_kit/managers/owner_manager.py +94 -0
- safe_kit/managers/token_manager.py +100 -0
- safe_kit/multisend.py +41 -0
- safe_kit/py.typed +0 -0
- safe_kit/safe.py +386 -0
- safe_kit/service.py +343 -0
- safe_kit/types.py +239 -0
- safe_kit-0.0.11.dist-info/METADATA +137 -0
- safe_kit-0.0.11.dist-info/RECORD +20 -0
- safe_kit-0.0.11.dist-info/WHEEL +4 -0
- safe_kit-0.0.11.dist-info/licenses/LICENSE +21 -0
safe_kit/safe.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
|
|
3
|
+
from hexbytes import HexBytes
|
|
4
|
+
|
|
5
|
+
from safe_kit.adapter import EthAdapter
|
|
6
|
+
from safe_kit.contract_types import (
|
|
7
|
+
SafeApproveHashParams,
|
|
8
|
+
SafeExecTransactionParams,
|
|
9
|
+
SafeGetTransactionHashParams,
|
|
10
|
+
SafeIsOwnerParams,
|
|
11
|
+
SafeRequiredTxGasParams,
|
|
12
|
+
)
|
|
13
|
+
from safe_kit.errors import handle_contract_error
|
|
14
|
+
from safe_kit.managers import (
|
|
15
|
+
GuardManagerMixin,
|
|
16
|
+
ModuleManagerMixin,
|
|
17
|
+
OwnerManagerMixin,
|
|
18
|
+
TokenManagerMixin,
|
|
19
|
+
)
|
|
20
|
+
from safe_kit.types import SafeTransaction, SafeTransactionData
|
|
21
|
+
|
|
22
|
+
EIP1271_MAGIC_VALUE = "0x1626ba7e"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Safe(
|
|
26
|
+
OwnerManagerMixin,
|
|
27
|
+
ModuleManagerMixin,
|
|
28
|
+
TokenManagerMixin,
|
|
29
|
+
GuardManagerMixin,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
The main class for interacting with a Safe.
|
|
33
|
+
|
|
34
|
+
This class provides a comprehensive interface for Safe operations including:
|
|
35
|
+
- Basic Safe info (address, version, balance, nonce, threshold, owners)
|
|
36
|
+
- Transaction creation, signing, and execution
|
|
37
|
+
- Owner management (add, remove, swap owners, change threshold)
|
|
38
|
+
- Module management (enable, disable, list modules)
|
|
39
|
+
- Token transfers (ERC20, ERC721, native ETH)
|
|
40
|
+
- Guard and fallback handler management
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, eth_adapter: EthAdapter, safe_address: str, chain_id: int | None = None
|
|
45
|
+
):
|
|
46
|
+
self.eth_adapter = eth_adapter
|
|
47
|
+
self.safe_address = self.eth_adapter.to_checksum_address(safe_address)
|
|
48
|
+
|
|
49
|
+
if not self.eth_adapter.is_contract(self.safe_address):
|
|
50
|
+
raise ValueError(f"Address {self.safe_address} is not a contract")
|
|
51
|
+
|
|
52
|
+
self.contract = self.eth_adapter.get_safe_contract(self.safe_address)
|
|
53
|
+
self.chain_id = chain_id
|
|
54
|
+
|
|
55
|
+
if self.chain_id is not None:
|
|
56
|
+
adapter_chain_id = self.eth_adapter.get_chain_id()
|
|
57
|
+
if adapter_chain_id != self.chain_id:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Adapter chain ID ({adapter_chain_id}) does not match "
|
|
60
|
+
f"Safe chain ID ({self.chain_id})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def create(
|
|
65
|
+
cls, eth_adapter: EthAdapter, safe_address: str, chain_id: int | None = None
|
|
66
|
+
) -> "Safe":
|
|
67
|
+
"""
|
|
68
|
+
Factory method to create a Safe instance.
|
|
69
|
+
"""
|
|
70
|
+
return cls(eth_adapter, safe_address, chain_id)
|
|
71
|
+
|
|
72
|
+
def get_address(self) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Returns the address of the Safe.
|
|
75
|
+
"""
|
|
76
|
+
return self.safe_address
|
|
77
|
+
|
|
78
|
+
def get_version(self) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Returns the version of the Safe contract.
|
|
81
|
+
"""
|
|
82
|
+
return cast(str, self.contract.functions.VERSION().call())
|
|
83
|
+
|
|
84
|
+
def get_balance(self) -> int:
|
|
85
|
+
"""
|
|
86
|
+
Returns the ETH balance of the Safe.
|
|
87
|
+
"""
|
|
88
|
+
return self.eth_adapter.get_balance(self.safe_address)
|
|
89
|
+
|
|
90
|
+
def get_nonce(self) -> int:
|
|
91
|
+
"""
|
|
92
|
+
Returns the current nonce of the Safe.
|
|
93
|
+
"""
|
|
94
|
+
return cast(int, self.contract.functions.nonce().call())
|
|
95
|
+
|
|
96
|
+
def get_threshold(self) -> int:
|
|
97
|
+
"""
|
|
98
|
+
Returns the threshold of the Safe.
|
|
99
|
+
"""
|
|
100
|
+
return cast(int, self.contract.functions.getThreshold().call())
|
|
101
|
+
|
|
102
|
+
def get_owners(self) -> list[str]:
|
|
103
|
+
"""
|
|
104
|
+
Returns the owners of the Safe.
|
|
105
|
+
"""
|
|
106
|
+
return cast(list[str], self.contract.functions.getOwners().call())
|
|
107
|
+
|
|
108
|
+
def is_owner(self, address: str) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Checks if an address is an owner of the Safe.
|
|
111
|
+
"""
|
|
112
|
+
params: SafeIsOwnerParams = {"owner": address}
|
|
113
|
+
return cast(bool, self.contract.functions.isOwner(**params).call())
|
|
114
|
+
|
|
115
|
+
def create_transaction(
|
|
116
|
+
self, transaction_data: SafeTransactionData
|
|
117
|
+
) -> SafeTransaction:
|
|
118
|
+
"""
|
|
119
|
+
Creates a Safe transaction ready to be signed.
|
|
120
|
+
"""
|
|
121
|
+
if transaction_data.nonce is None:
|
|
122
|
+
transaction_data.nonce = self.get_nonce()
|
|
123
|
+
|
|
124
|
+
return SafeTransaction(data=transaction_data)
|
|
125
|
+
|
|
126
|
+
def sign_transaction(
|
|
127
|
+
self, safe_transaction: SafeTransaction, method: str = "eth_sign_typed_data"
|
|
128
|
+
) -> SafeTransaction:
|
|
129
|
+
"""
|
|
130
|
+
Signs a Safe transaction with the current signer.
|
|
131
|
+
Supported methods: "eth_sign_typed_data" (EIP-712), "eth_sign" (legacy).
|
|
132
|
+
"""
|
|
133
|
+
signer_address = self.eth_adapter.get_signer_address()
|
|
134
|
+
if not signer_address:
|
|
135
|
+
raise ValueError("No signer configured in the adapter")
|
|
136
|
+
|
|
137
|
+
chain_id = self.eth_adapter.get_chain_id()
|
|
138
|
+
|
|
139
|
+
if method == "eth_sign_typed_data":
|
|
140
|
+
eip712_data = safe_transaction.data.get_eip712_data(
|
|
141
|
+
chain_id, self.safe_address
|
|
142
|
+
)
|
|
143
|
+
signature = self.eth_adapter.sign_typed_data(eip712_data)
|
|
144
|
+
elif method == "eth_sign":
|
|
145
|
+
tx_hash = self.get_transaction_hash(safe_transaction)
|
|
146
|
+
signature = self.eth_adapter.sign_message(tx_hash)
|
|
147
|
+
# Adjust v for eth_sign: v += 4
|
|
148
|
+
# Signature is r(32) + s(32) + v(1)
|
|
149
|
+
# We need to parse it, adjust v, and reconstruct
|
|
150
|
+
sig_bytes = HexBytes(signature)
|
|
151
|
+
r = sig_bytes[:32]
|
|
152
|
+
s = sig_bytes[32:64]
|
|
153
|
+
v = sig_bytes[64]
|
|
154
|
+
v += 4
|
|
155
|
+
signature = (r + s + bytes([v])).hex()
|
|
156
|
+
else:
|
|
157
|
+
raise ValueError(f"Unsupported signing method: {method}")
|
|
158
|
+
|
|
159
|
+
safe_transaction.add_signature(signer_address, signature)
|
|
160
|
+
return safe_transaction
|
|
161
|
+
|
|
162
|
+
def add_signature(
|
|
163
|
+
self, safe_transaction: SafeTransaction, owner_address: str, signature: str
|
|
164
|
+
) -> SafeTransaction:
|
|
165
|
+
"""
|
|
166
|
+
Adds a signature to a Safe transaction.
|
|
167
|
+
"""
|
|
168
|
+
owner_address = self.eth_adapter.to_checksum_address(owner_address)
|
|
169
|
+
safe_transaction.add_signature(owner_address, signature)
|
|
170
|
+
return safe_transaction
|
|
171
|
+
|
|
172
|
+
def add_prevalidated_signature(
|
|
173
|
+
self, safe_transaction: SafeTransaction, owner_address: str
|
|
174
|
+
) -> SafeTransaction:
|
|
175
|
+
"""
|
|
176
|
+
Adds a pre-validated signature for a given owner.
|
|
177
|
+
v=1, r=owner, s=0.
|
|
178
|
+
"""
|
|
179
|
+
owner_address = self.eth_adapter.to_checksum_address(owner_address)
|
|
180
|
+
# Signature: r(32) + s(32) + v(1)
|
|
181
|
+
# r = owner address, padded to 32 bytes
|
|
182
|
+
# s = 0, padded to 32 bytes
|
|
183
|
+
# v = 1
|
|
184
|
+
r = owner_address.lower().replace("0x", "").zfill(64)
|
|
185
|
+
s = "0" * 64
|
|
186
|
+
v = "01"
|
|
187
|
+
signature = "0x" + r + s + v
|
|
188
|
+
safe_transaction.add_signature(owner_address, signature)
|
|
189
|
+
return safe_transaction
|
|
190
|
+
|
|
191
|
+
def get_transaction_hash(self, safe_transaction: SafeTransaction) -> str:
|
|
192
|
+
"""
|
|
193
|
+
Returns the hash of the Safe transaction.
|
|
194
|
+
"""
|
|
195
|
+
params: SafeGetTransactionHashParams = {
|
|
196
|
+
"to": safe_transaction.data.to,
|
|
197
|
+
"value": safe_transaction.data.value,
|
|
198
|
+
"data": HexBytes(safe_transaction.data.data),
|
|
199
|
+
"operation": safe_transaction.data.operation,
|
|
200
|
+
"safeTxGas": safe_transaction.data.safe_tx_gas,
|
|
201
|
+
"baseGas": safe_transaction.data.base_gas,
|
|
202
|
+
"gasPrice": safe_transaction.data.gas_price,
|
|
203
|
+
"gasToken": safe_transaction.data.gas_token,
|
|
204
|
+
"refundReceiver": safe_transaction.data.refund_receiver,
|
|
205
|
+
"_nonce": (
|
|
206
|
+
safe_transaction.data.nonce
|
|
207
|
+
if safe_transaction.data.nonce is not None
|
|
208
|
+
else 0
|
|
209
|
+
),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return cast(
|
|
213
|
+
str,
|
|
214
|
+
self.contract.functions.getTransactionHash(**params).call().hex(),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def approve_hash(self, hash_to_approve: str) -> str:
|
|
218
|
+
"""
|
|
219
|
+
Approves a hash on-chain.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
params: SafeApproveHashParams = {"hashToApprove": HexBytes(hash_to_approve)}
|
|
223
|
+
tx_hash = self.contract.functions.approveHash(**params).transact(
|
|
224
|
+
{"from": self.eth_adapter.get_signer_address()}
|
|
225
|
+
)
|
|
226
|
+
return cast(str, tx_hash.hex())
|
|
227
|
+
except Exception as e:
|
|
228
|
+
raise handle_contract_error(e) from e
|
|
229
|
+
|
|
230
|
+
def execute_transaction(self, safe_transaction: SafeTransaction) -> str:
|
|
231
|
+
"""
|
|
232
|
+
Executes a Safe transaction.
|
|
233
|
+
"""
|
|
234
|
+
# Sort signatures
|
|
235
|
+
sorted_signatures = safe_transaction.sorted_signatures_bytes
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
params: SafeExecTransactionParams = {
|
|
239
|
+
"to": safe_transaction.data.to,
|
|
240
|
+
"value": safe_transaction.data.value,
|
|
241
|
+
"data": HexBytes(safe_transaction.data.data),
|
|
242
|
+
"operation": safe_transaction.data.operation,
|
|
243
|
+
"safeTxGas": safe_transaction.data.safe_tx_gas,
|
|
244
|
+
"baseGas": safe_transaction.data.base_gas,
|
|
245
|
+
"gasPrice": safe_transaction.data.gas_price,
|
|
246
|
+
"gasToken": safe_transaction.data.gas_token,
|
|
247
|
+
"refundReceiver": safe_transaction.data.refund_receiver,
|
|
248
|
+
"signatures": sorted_signatures,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
tx_hash = self.contract.functions.execTransaction(**params).transact(
|
|
252
|
+
{"from": self.eth_adapter.get_signer_address()}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return cast(str, tx_hash.hex())
|
|
256
|
+
except Exception as e:
|
|
257
|
+
raise handle_contract_error(e) from e
|
|
258
|
+
|
|
259
|
+
def simulate_transaction(self, safe_transaction: SafeTransaction) -> bool:
|
|
260
|
+
"""
|
|
261
|
+
Simulates the transaction using eth_call.
|
|
262
|
+
Returns True if the transaction would succeed, False otherwise.
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
params: SafeExecTransactionParams = {
|
|
266
|
+
"to": safe_transaction.data.to,
|
|
267
|
+
"value": safe_transaction.data.value,
|
|
268
|
+
"data": HexBytes(safe_transaction.data.data),
|
|
269
|
+
"operation": safe_transaction.data.operation,
|
|
270
|
+
"safeTxGas": safe_transaction.data.safe_tx_gas,
|
|
271
|
+
"baseGas": safe_transaction.data.base_gas,
|
|
272
|
+
"gasPrice": safe_transaction.data.gas_price,
|
|
273
|
+
"gasToken": safe_transaction.data.gas_token,
|
|
274
|
+
"refundReceiver": safe_transaction.data.refund_receiver,
|
|
275
|
+
"signatures": safe_transaction.sorted_signatures_bytes,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Use call() to simulate
|
|
279
|
+
success = self.contract.functions.execTransaction(**params).call(
|
|
280
|
+
{"from": self.eth_adapter.get_signer_address()}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return cast(bool, success)
|
|
284
|
+
except Exception:
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
def estimate_transaction_gas(self, safe_transaction: SafeTransaction) -> int:
|
|
288
|
+
"""
|
|
289
|
+
Estimates the gas required for a Safe transaction.
|
|
290
|
+
"""
|
|
291
|
+
params: SafeRequiredTxGasParams = {
|
|
292
|
+
"to": safe_transaction.data.to,
|
|
293
|
+
"value": safe_transaction.data.value,
|
|
294
|
+
"data": HexBytes(safe_transaction.data.data),
|
|
295
|
+
"operation": safe_transaction.data.operation,
|
|
296
|
+
"safeTxGas": safe_transaction.data.safe_tx_gas,
|
|
297
|
+
"baseGas": safe_transaction.data.base_gas,
|
|
298
|
+
"gasPrice": safe_transaction.data.gas_price,
|
|
299
|
+
"gasToken": safe_transaction.data.gas_token,
|
|
300
|
+
"refundReceiver": safe_transaction.data.refund_receiver,
|
|
301
|
+
"signatures": safe_transaction.sorted_signatures_bytes,
|
|
302
|
+
}
|
|
303
|
+
return cast(
|
|
304
|
+
int,
|
|
305
|
+
self.contract.functions.requiredTxGas(**params).call(),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def check_signatures(self, safe_transaction: SafeTransaction) -> None:
|
|
309
|
+
"""
|
|
310
|
+
Checks if the signatures on the transaction are valid.
|
|
311
|
+
Raises an error if signatures are invalid.
|
|
312
|
+
"""
|
|
313
|
+
tx_hash = self.get_transaction_hash(safe_transaction)
|
|
314
|
+
# Convert hex string hash to bytes
|
|
315
|
+
tx_hash_bytes = HexBytes(tx_hash)
|
|
316
|
+
|
|
317
|
+
self.contract.functions.checkSignatures(
|
|
318
|
+
tx_hash_bytes,
|
|
319
|
+
HexBytes(safe_transaction.data.data),
|
|
320
|
+
safe_transaction.sorted_signatures_bytes,
|
|
321
|
+
).call()
|
|
322
|
+
|
|
323
|
+
def wait_for_transaction(self, tx_hash: str, timeout: int = 120) -> Any:
|
|
324
|
+
"""
|
|
325
|
+
Waits for a transaction receipt.
|
|
326
|
+
"""
|
|
327
|
+
return self.eth_adapter.wait_for_transaction_receipt(tx_hash, timeout=timeout)
|
|
328
|
+
|
|
329
|
+
def get_domain_separator(self) -> str:
|
|
330
|
+
"""
|
|
331
|
+
Returns the EIP-712 domain separator of the Safe.
|
|
332
|
+
"""
|
|
333
|
+
return cast(str, self.contract.functions.domainSeparator().call().hex())
|
|
334
|
+
|
|
335
|
+
def get_message_hash(self, message: str | bytes) -> str:
|
|
336
|
+
"""
|
|
337
|
+
Returns the safe message hash for a given message.
|
|
338
|
+
"""
|
|
339
|
+
if isinstance(message, str):
|
|
340
|
+
if message.startswith("0x"):
|
|
341
|
+
message_bytes = HexBytes(message)
|
|
342
|
+
else:
|
|
343
|
+
message_bytes = HexBytes(message.encode("utf-8"))
|
|
344
|
+
elif isinstance(message, bytes):
|
|
345
|
+
message_bytes = HexBytes(message)
|
|
346
|
+
else:
|
|
347
|
+
raise TypeError("message must be str or bytes")
|
|
348
|
+
|
|
349
|
+
# keccak256(message)
|
|
350
|
+
from eth_hash.auto import keccak
|
|
351
|
+
|
|
352
|
+
message_hash = keccak(message_bytes)
|
|
353
|
+
|
|
354
|
+
result = self.contract.functions.getMessageHash(message_hash).call().hex()
|
|
355
|
+
if not result.startswith("0x"):
|
|
356
|
+
result = "0x" + result
|
|
357
|
+
return cast(str, result)
|
|
358
|
+
|
|
359
|
+
def sign_message(self, message: str | bytes) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Signs a message hash using the current signer.
|
|
362
|
+
Returns the signature using eth_sign (EIP-191).
|
|
363
|
+
"""
|
|
364
|
+
message_hash = self.get_message_hash(message)
|
|
365
|
+
return self.eth_adapter.sign_message(message_hash)
|
|
366
|
+
|
|
367
|
+
def is_valid_signature(
|
|
368
|
+
self, message_hash: str | bytes, signature: str | bytes
|
|
369
|
+
) -> bool:
|
|
370
|
+
"""
|
|
371
|
+
Checks if a signature is valid for a given message hash using EIP-1271.
|
|
372
|
+
"""
|
|
373
|
+
if isinstance(message_hash, str):
|
|
374
|
+
message_hash = HexBytes(message_hash)
|
|
375
|
+
if isinstance(signature, str):
|
|
376
|
+
signature = HexBytes(signature)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
# isValidSignature(bytes32 _data, bytes memory _signature)
|
|
380
|
+
# returns (bytes4)
|
|
381
|
+
result = self.contract.functions.isValidSignature(
|
|
382
|
+
message_hash, signature
|
|
383
|
+
).call()
|
|
384
|
+
return HexBytes(result) == HexBytes(EIP1271_MAGIC_VALUE)
|
|
385
|
+
except Exception:
|
|
386
|
+
return False
|