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/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