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/errors.py ADDED
@@ -0,0 +1,57 @@
1
+ class SafeKitError(Exception):
2
+ """Base exception for safe-kit"""
3
+
4
+ pass
5
+
6
+
7
+ class SafeTransactionError(SafeKitError):
8
+ """Exception raised when a Safe transaction fails"""
9
+
10
+ def __init__(self, message: str, error_code: str | None = None):
11
+ self.error_code = error_code
12
+ super().__init__(f"{error_code}: {message}" if error_code else message)
13
+
14
+
15
+ class SafeServiceError(SafeKitError):
16
+ """Exception raised when the Safe Transaction Service returns an error"""
17
+
18
+ def __init__(self, message: str, status_code: int | None = None):
19
+ self.status_code = status_code
20
+ super().__init__(f"Status {status_code}: {message}" if status_code else message)
21
+
22
+
23
+ SAFE_ERRORS = {
24
+ "GS000": "Could not finish initialization",
25
+ "GS001": "Threshold needs to be defined",
26
+ "GS010": "Not enough gas to execute Safe transaction",
27
+ "GS011": "Could not pay gas costs with ether",
28
+ "GS012": "Could not pay gas costs with token",
29
+ "GS013": "Safe transaction failed when gasPrice and safeTxGas were 0",
30
+ "GS020": "Signatures data too short",
31
+ "GS021": "Invalid signature provided",
32
+ "GS022": "Invalid signature provided (duplicate)",
33
+ "GS023": "Invalid signature provided (not owner)",
34
+ "GS024": "Invalid signature provided (not sorted)",
35
+ "GS025": "Invalid signature provided (v is 0)",
36
+ "GS026": "Invalid signature provided (v > 30)",
37
+ "GS030": "Only owners can approve a hash",
38
+ "GS031": "Hash has already been approved",
39
+ "GS100": "Modules have already been initialized",
40
+ "GS130": "New owner cannot be the null address",
41
+ }
42
+
43
+
44
+ def handle_contract_error(e: Exception) -> Exception:
45
+ """
46
+ Parses a web3 exception and returns a more readable SafeKitError if possible.
47
+ """
48
+ error_str = str(e)
49
+
50
+ # Check for Safe error codes in the exception message
51
+ for code, message in SAFE_ERRORS.items():
52
+ if code in error_str:
53
+ return SafeTransactionError(message, error_code=code)
54
+
55
+ # If no specific Safe error is found, return the original exception
56
+ # or wrap it in a generic SafeKitError
57
+ return e
safe_kit/factory.py ADDED
@@ -0,0 +1,143 @@
1
+ from typing import cast
2
+
3
+ from hexbytes import HexBytes
4
+
5
+ from safe_kit.abis import SAFE_PROXY_FACTORY_ABI
6
+ from safe_kit.adapter import EthAdapter
7
+ from safe_kit.contract_types import (
8
+ SafeProxyFactoryCreateChainSpecificProxyWithNonceParams,
9
+ SafeProxyFactoryCreateProxyWithNonceParams,
10
+ )
11
+ from safe_kit.errors import handle_contract_error
12
+ from safe_kit.safe import Safe
13
+ from safe_kit.types import SafeAccountConfig
14
+
15
+
16
+ class SafeFactory:
17
+ """
18
+ Factory class to deploy new Safe contracts.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ eth_adapter: EthAdapter,
24
+ safe_singleton_address: str,
25
+ safe_proxy_factory_address: str,
26
+ ):
27
+ self.eth_adapter = eth_adapter
28
+ self.safe_singleton_address = safe_singleton_address
29
+ self.safe_proxy_factory_address = safe_proxy_factory_address
30
+ self.proxy_factory_contract = self.eth_adapter.get_contract(
31
+ address=safe_proxy_factory_address, abi=SAFE_PROXY_FACTORY_ABI
32
+ )
33
+
34
+ def _get_initializer_data(self, config: SafeAccountConfig) -> bytes:
35
+ safe_singleton = self.eth_adapter.get_safe_contract(self.safe_singleton_address)
36
+ return cast(
37
+ bytes,
38
+ safe_singleton.encodeABI(
39
+ fn_name="setup",
40
+ args=[
41
+ config.owners,
42
+ config.threshold,
43
+ config.to,
44
+ HexBytes(config.data),
45
+ config.fallback_handler,
46
+ config.payment_token,
47
+ config.payment,
48
+ config.payment_receiver,
49
+ ],
50
+ ),
51
+ )
52
+
53
+ def predict_safe_address(
54
+ self, config: SafeAccountConfig, salt_nonce: int = 0
55
+ ) -> str:
56
+ """
57
+ Predicts the address of the Safe that would be deployed with the given
58
+ configuration.
59
+ """
60
+ initializer = self._get_initializer_data(config)
61
+ params: SafeProxyFactoryCreateProxyWithNonceParams = {
62
+ "_singleton": self.safe_singleton_address,
63
+ "initializer": initializer,
64
+ "saltNonce": salt_nonce,
65
+ }
66
+ return cast(
67
+ str,
68
+ self.proxy_factory_contract.functions.createProxyWithNonce(**params).call(),
69
+ )
70
+
71
+ def predict_safe_address_v1_4_1(
72
+ self, config: SafeAccountConfig, salt_nonce: int = 0
73
+ ) -> str:
74
+ """
75
+ Predicts the address of the Safe (v1.4.1) that would be deployed.
76
+ Uses createChainSpecificProxyWithNonce.
77
+ """
78
+ initializer = self._get_initializer_data(config)
79
+ params: SafeProxyFactoryCreateChainSpecificProxyWithNonceParams = {
80
+ "_singleton": self.safe_singleton_address,
81
+ "initializer": initializer,
82
+ "saltNonce": salt_nonce,
83
+ }
84
+ return cast(
85
+ str,
86
+ self.proxy_factory_contract.functions.createChainSpecificProxyWithNonce(
87
+ **params
88
+ ).call(),
89
+ )
90
+
91
+ def deploy_safe(self, config: SafeAccountConfig, salt_nonce: int = 0) -> Safe:
92
+ """
93
+ Deploys a new Safe contract.
94
+ Returns a Safe instance with the predicted address.
95
+ """
96
+ signer = self.eth_adapter.get_signer_address()
97
+ if not signer:
98
+ raise ValueError("No signer configured in the adapter")
99
+
100
+ initializer = self._get_initializer_data(config)
101
+ safe_address = self.predict_safe_address(config, salt_nonce)
102
+
103
+ try:
104
+ params: SafeProxyFactoryCreateProxyWithNonceParams = {
105
+ "_singleton": self.safe_singleton_address,
106
+ "initializer": initializer,
107
+ "saltNonce": salt_nonce,
108
+ }
109
+ self.proxy_factory_contract.functions.createProxyWithNonce(
110
+ **params
111
+ ).transact({"from": signer})
112
+ except Exception as e:
113
+ raise handle_contract_error(e) from e
114
+
115
+ return Safe(self.eth_adapter, safe_address)
116
+
117
+ def deploy_safe_v1_4_1(
118
+ self, config: SafeAccountConfig, salt_nonce: int = 0
119
+ ) -> Safe:
120
+ """
121
+ Deploys a new Safe contract (v1.4.1).
122
+ Returns a Safe instance with the predicted address.
123
+ """
124
+ signer = self.eth_adapter.get_signer_address()
125
+ if not signer:
126
+ raise ValueError("No signer configured in the adapter")
127
+
128
+ initializer = self._get_initializer_data(config)
129
+ safe_address = self.predict_safe_address_v1_4_1(config, salt_nonce)
130
+
131
+ try:
132
+ params: SafeProxyFactoryCreateChainSpecificProxyWithNonceParams = {
133
+ "_singleton": self.safe_singleton_address,
134
+ "initializer": initializer,
135
+ "saltNonce": salt_nonce,
136
+ }
137
+ self.proxy_factory_contract.functions.createChainSpecificProxyWithNonce(
138
+ **params
139
+ ).transact({"from": signer})
140
+ except Exception as e:
141
+ raise handle_contract_error(e) from e
142
+
143
+ return Safe(self.eth_adapter, safe_address)
@@ -0,0 +1,15 @@
1
+ """
2
+ Safe manager mixins for modular functionality.
3
+ """
4
+
5
+ from safe_kit.managers.guard_manager import GuardManagerMixin
6
+ from safe_kit.managers.module_manager import ModuleManagerMixin
7
+ from safe_kit.managers.owner_manager import OwnerManagerMixin
8
+ from safe_kit.managers.token_manager import TokenManagerMixin
9
+
10
+ __all__ = [
11
+ "OwnerManagerMixin",
12
+ "ModuleManagerMixin",
13
+ "TokenManagerMixin",
14
+ "GuardManagerMixin",
15
+ ]
@@ -0,0 +1,61 @@
1
+ """
2
+ Guard and fallback handler management functionality for Safe.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from safe_kit.types import SafeTransaction, SafeTransactionData
8
+
9
+ if TYPE_CHECKING:
10
+ from safe_kit.safe import Safe
11
+
12
+
13
+ class GuardManagerMixin:
14
+ """
15
+ Mixin class providing guard and fallback handler management functionality.
16
+ """
17
+
18
+ def get_guard(self: "Safe") -> str: # type: ignore[misc]
19
+ """
20
+ Returns the guard address of the Safe.
21
+ """
22
+ # keccak256("guard_manager.guard.address")
23
+ slot = 0x4A204F620C8C5CCDCA3FD54D003B6D13435454A733A569F8E4A6426EA62BF7A0
24
+ data = self.eth_adapter.get_storage_at(self.safe_address, slot)
25
+ # Convert bytes to address (last 20 bytes)
26
+ return "0x" + data.hex()[-40:]
27
+
28
+ def create_set_guard_transaction( # type: ignore[misc]
29
+ self: "Safe", guard_address: str
30
+ ) -> SafeTransaction:
31
+ """
32
+ Creates a transaction to set the guard of the Safe.
33
+ """
34
+ data = self.contract.encodeABI(fn_name="setGuard", args=[guard_address])
35
+
36
+ return self.create_transaction(
37
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
38
+ )
39
+
40
+ def get_fallback_handler(self: "Safe") -> str: # type: ignore[misc]
41
+ """
42
+ Returns the fallback handler address of the Safe.
43
+ """
44
+ # keccak256("fallback_manager.handler.address")
45
+ slot = 0x6C9A6C4A39284E37ED1CF53D337577D14212A4870FB976A4366C693B939918D5
46
+ data = self.eth_adapter.get_storage_at(self.safe_address, slot)
47
+ return "0x" + data.hex()[-40:]
48
+
49
+ def create_set_fallback_handler_transaction( # type: ignore[misc]
50
+ self: "Safe", handler_address: str
51
+ ) -> SafeTransaction:
52
+ """
53
+ Creates a transaction to set the fallback handler of the Safe.
54
+ """
55
+ data = self.contract.encodeABI(
56
+ fn_name="setFallbackHandler", args=[handler_address]
57
+ )
58
+
59
+ return self.create_transaction(
60
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
61
+ )
@@ -0,0 +1,85 @@
1
+ """
2
+ Module management functionality for Safe.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from safe_kit.types import SafeTransaction, SafeTransactionData
8
+
9
+ if TYPE_CHECKING:
10
+ from safe_kit.safe import Safe
11
+
12
+
13
+ class ModuleManagerMixin:
14
+ """
15
+ Mixin class providing module management functionality.
16
+ """
17
+
18
+ def get_modules(self: "Safe") -> list[str]: # type: ignore[misc]
19
+ """
20
+ Returns the modules enabled on the Safe.
21
+ """
22
+ # Sentinel address for modules
23
+ start = "0x0000000000000000000000000000000000000001"
24
+ page_size = 10
25
+ modules = []
26
+
27
+ while True:
28
+ array, next_module = self.contract.functions.getModulesPaginated(
29
+ start, page_size
30
+ ).call()
31
+ modules.extend(array)
32
+
33
+ if (
34
+ next_module == "0x0000000000000000000000000000000000000001"
35
+ or next_module == "0x0000000000000000000000000000000000000000"
36
+ ):
37
+ break
38
+ start = next_module
39
+
40
+ return modules
41
+
42
+ def is_module_enabled(self: "Safe", module_address: str) -> bool: # type: ignore[misc]
43
+ """
44
+ Checks if a module is enabled on the Safe.
45
+ """
46
+ return cast(
47
+ bool, self.contract.functions.isModuleEnabled(module_address).call()
48
+ )
49
+
50
+ def create_enable_module_transaction( # type: ignore[misc]
51
+ self: "Safe", module_address: str
52
+ ) -> SafeTransaction:
53
+ """
54
+ Creates a transaction to enable a Safe module.
55
+ """
56
+ data = self.contract.encodeABI(fn_name="enableModule", args=[module_address])
57
+
58
+ return self.create_transaction(
59
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
60
+ )
61
+
62
+ def create_disable_module_transaction( # type: ignore[misc]
63
+ self: "Safe", module_address: str
64
+ ) -> SafeTransaction:
65
+ """
66
+ Creates a transaction to disable a Safe module.
67
+ """
68
+ modules = self.get_modules()
69
+ try:
70
+ index = modules.index(module_address)
71
+ except ValueError:
72
+ raise ValueError(f"Module {module_address} is not enabled") from None
73
+
74
+ if index == 0:
75
+ prev_module = "0x0000000000000000000000000000000000000001"
76
+ else:
77
+ prev_module = modules[index - 1]
78
+
79
+ data = self.contract.encodeABI(
80
+ fn_name="disableModule", args=[prev_module, module_address]
81
+ )
82
+
83
+ return self.create_transaction(
84
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
85
+ )
@@ -0,0 +1,94 @@
1
+ """
2
+ Owner management functionality for Safe.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from safe_kit.types import SafeTransaction, SafeTransactionData
8
+
9
+ if TYPE_CHECKING:
10
+ from safe_kit.safe import Safe
11
+
12
+
13
+ class OwnerManagerMixin:
14
+ """
15
+ Mixin class providing owner management functionality.
16
+ """
17
+
18
+ def _get_previous_owner(self: "Safe", owner: str) -> str: # type: ignore[misc]
19
+ """
20
+ Get the previous owner in the linked list for removal/swap operations.
21
+ """
22
+ owners = self.get_owners()
23
+ try:
24
+ index = owners.index(owner)
25
+ except ValueError:
26
+ raise ValueError(f"Address {owner} is not an owner") from None
27
+
28
+ if index == 0:
29
+ return "0x0000000000000000000000000000000000000001" # Sentinel
30
+ return owners[index - 1]
31
+
32
+ def create_add_owner_transaction( # type: ignore[misc]
33
+ self: "Safe", owner: str, threshold: int | None = None
34
+ ) -> SafeTransaction:
35
+ """
36
+ Creates a transaction to add a new owner to the Safe.
37
+ """
38
+ if threshold is None:
39
+ threshold = self.get_threshold()
40
+
41
+ data = self.contract.encodeABI(
42
+ fn_name="addOwnerWithThreshold", args=[owner, threshold]
43
+ )
44
+
45
+ return self.create_transaction(
46
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
47
+ )
48
+
49
+ def create_remove_owner_transaction( # type: ignore[misc]
50
+ self: "Safe", owner: str, threshold: int | None = None
51
+ ) -> SafeTransaction:
52
+ """
53
+ Creates a transaction to remove an owner from the Safe.
54
+ """
55
+ if threshold is None:
56
+ threshold = self.get_threshold()
57
+
58
+ prev_owner = self._get_previous_owner(owner)
59
+
60
+ data = self.contract.encodeABI(
61
+ fn_name="removeOwner", args=[prev_owner, owner, threshold]
62
+ )
63
+
64
+ return self.create_transaction(
65
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
66
+ )
67
+
68
+ def create_swap_owner_transaction( # type: ignore[misc]
69
+ self: "Safe", old_owner: str, new_owner: str
70
+ ) -> SafeTransaction:
71
+ """
72
+ Creates a transaction to replace an existing owner with a new one.
73
+ """
74
+ prev_owner = self._get_previous_owner(old_owner)
75
+
76
+ data = self.contract.encodeABI(
77
+ fn_name="swapOwner", args=[prev_owner, old_owner, new_owner]
78
+ )
79
+
80
+ return self.create_transaction(
81
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
82
+ )
83
+
84
+ def create_change_threshold_transaction( # type: ignore[misc]
85
+ self: "Safe", threshold: int
86
+ ) -> SafeTransaction:
87
+ """
88
+ Creates a transaction to change the threshold of the Safe.
89
+ """
90
+ data = self.contract.encodeABI(fn_name="changeThreshold", args=[threshold])
91
+
92
+ return self.create_transaction(
93
+ SafeTransactionData(to=self.safe_address, value=0, data=data, operation=0)
94
+ )
@@ -0,0 +1,100 @@
1
+ """
2
+ Token transfer functionality for Safe.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from safe_kit.types import SafeTransaction, SafeTransactionData
8
+
9
+ if TYPE_CHECKING:
10
+ from safe_kit.safe import Safe
11
+
12
+
13
+ class TokenManagerMixin:
14
+ """
15
+ Mixin class providing token transfer functionality.
16
+ """
17
+
18
+ def create_erc20_transfer_transaction( # type: ignore[misc]
19
+ self: "Safe", token_address: str, to: str, amount: int
20
+ ) -> SafeTransaction:
21
+ """
22
+ Creates a transaction to transfer ERC20 tokens.
23
+ """
24
+ from safe_kit.abis import ERC20_ABI
25
+
26
+ token_contract = self.eth_adapter.get_contract(token_address, ERC20_ABI)
27
+
28
+ data = token_contract.encodeABI(fn_name="transfer", args=[to, amount])
29
+
30
+ return self.create_transaction(
31
+ SafeTransactionData(to=token_address, value=0, data=data, operation=0)
32
+ )
33
+
34
+ def create_erc721_transfer_transaction( # type: ignore[misc]
35
+ self: "Safe", token_address: str, to: str, token_id: int
36
+ ) -> SafeTransaction:
37
+ """
38
+ Creates a transaction to transfer ERC721 tokens.
39
+ """
40
+ from safe_kit.abis import ERC721_ABI
41
+
42
+ token_contract = self.eth_adapter.get_contract(token_address, ERC721_ABI)
43
+
44
+ data = token_contract.encodeABI(
45
+ fn_name="safeTransferFrom", args=[self.safe_address, to, token_id]
46
+ )
47
+
48
+ return self.create_transaction(
49
+ SafeTransactionData(to=token_address, value=0, data=data, operation=0)
50
+ )
51
+
52
+ def create_native_transfer_transaction( # type: ignore[misc]
53
+ self: "Safe", to: str, amount: int
54
+ ) -> SafeTransaction:
55
+ """
56
+ Creates a transaction to transfer native tokens (ETH).
57
+ """
58
+ return self.create_transaction(
59
+ SafeTransactionData(to=to, value=amount, data="0x", operation=0)
60
+ )
61
+
62
+ def create_rejection_transaction(self: "Safe", nonce: int) -> SafeTransaction: # type: ignore[misc]
63
+ """
64
+ Creates a transaction to reject a pending transaction (by reusing the nonce).
65
+ """
66
+ return self.create_transaction(
67
+ SafeTransactionData(
68
+ to=self.safe_address, value=0, data="0x", operation=0, nonce=nonce
69
+ )
70
+ )
71
+
72
+ def create_multi_send_transaction( # type: ignore[misc]
73
+ self: "Safe",
74
+ transactions: list[SafeTransactionData],
75
+ multi_send_address: str,
76
+ ) -> SafeTransaction:
77
+ """
78
+ Creates a MultiSend transaction.
79
+ """
80
+ from safe_kit.abis import MULTI_SEND_ABI
81
+ from safe_kit.multisend import MultiSend
82
+
83
+ encoded_txs = MultiSend.encode_transactions(transactions)
84
+
85
+ multi_send_contract = self.eth_adapter.get_contract(
86
+ multi_send_address, MULTI_SEND_ABI
87
+ )
88
+
89
+ data = multi_send_contract.encodeABI(fn_name="multiSend", args=[encoded_txs])
90
+
91
+ # MultiSend transactions are DelegateCalls (operation=1)
92
+ # to the MultiSend contract
93
+ return self.create_transaction(
94
+ SafeTransactionData(
95
+ to=multi_send_address,
96
+ value=0,
97
+ data=data,
98
+ operation=1, # DelegateCall
99
+ )
100
+ )
safe_kit/multisend.py ADDED
@@ -0,0 +1,41 @@
1
+ from hexbytes import HexBytes
2
+
3
+ from safe_kit.types import SafeTransactionData
4
+
5
+
6
+ class MultiSend:
7
+ """
8
+ Utilities for encoding MultiSend transactions.
9
+ """
10
+
11
+ @staticmethod
12
+ def encode_transactions(transactions: list[SafeTransactionData]) -> bytes:
13
+ """
14
+ Encodes a list of SafeTransactionData into a single byte string
15
+ compatible with the MultiSend contract.
16
+
17
+ Format:
18
+ operation (1 byte) + to (20 bytes) + value (32 bytes) +
19
+ data_length (32 bytes) + data (bytes)
20
+ """
21
+ encoded_data = b""
22
+ for tx in transactions:
23
+ # operation: 1 byte
24
+ operation = int(tx.operation).to_bytes(1, byteorder="big")
25
+
26
+ # to: 20 bytes
27
+ # Ensure it's bytes and 20 bytes long
28
+ to_address = HexBytes(tx.to)
29
+ if len(to_address) != 20:
30
+ raise ValueError(f"Invalid address length for {tx.to}")
31
+
32
+ # value: 32 bytes
33
+ value = int(tx.value).to_bytes(32, byteorder="big")
34
+
35
+ # data
36
+ data = HexBytes(tx.data)
37
+ data_length = len(data).to_bytes(32, byteorder="big")
38
+
39
+ encoded_data += operation + to_address + value + data_length + data
40
+
41
+ return encoded_data
safe_kit/py.typed ADDED
File without changes