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