iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Service contract interaction."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
from iwa.core.contracts.contract import ContractInstance
|
|
8
|
+
from iwa.core.types import EthereumAddress
|
|
9
|
+
from iwa.plugins.olas.constants import (
|
|
10
|
+
DEFAULT_DEPLOY_PAYLOAD,
|
|
11
|
+
)
|
|
12
|
+
from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_deployment_payload(fallback_handler: str) -> str:
|
|
16
|
+
"""Calculates deployment payload."""
|
|
17
|
+
return (
|
|
18
|
+
DEFAULT_DEPLOY_PAYLOAD.format(fallback_handler=fallback_handler[2:])
|
|
19
|
+
+ int(time.time()).to_bytes(32, "big").hex()
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ServiceState(Enum):
|
|
24
|
+
"""Enumeration of possible service states."""
|
|
25
|
+
|
|
26
|
+
NON_EXISTENT = 0
|
|
27
|
+
PRE_REGISTRATION = 1
|
|
28
|
+
ACTIVE_REGISTRATION = 2
|
|
29
|
+
FINISHED_REGISTRATION = 3
|
|
30
|
+
DEPLOYED = 4
|
|
31
|
+
TERMINATED_BONDED = 5
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ServiceRegistryContract(ContractInstance):
|
|
35
|
+
"""Class to interact with the service registry contract."""
|
|
36
|
+
|
|
37
|
+
name = "service_registry"
|
|
38
|
+
abi_path = OLAS_ABI_PATH / "service_registry.json"
|
|
39
|
+
|
|
40
|
+
def get_service(self, service_id: int) -> Dict:
|
|
41
|
+
"""Get the IDs of all registered services."""
|
|
42
|
+
(
|
|
43
|
+
security_deposit,
|
|
44
|
+
multisig,
|
|
45
|
+
config_hash,
|
|
46
|
+
threshold,
|
|
47
|
+
max_num_agent_instances,
|
|
48
|
+
num_agent_instances,
|
|
49
|
+
state,
|
|
50
|
+
agent_ids,
|
|
51
|
+
) = self.call("getService", service_id)
|
|
52
|
+
return {
|
|
53
|
+
"security_deposit": security_deposit,
|
|
54
|
+
"multisig": multisig,
|
|
55
|
+
"config_hash": config_hash.hex(),
|
|
56
|
+
"threshold": threshold,
|
|
57
|
+
"max_num_agent_instances": max_num_agent_instances,
|
|
58
|
+
"num_agent_instances": num_agent_instances,
|
|
59
|
+
"state": ServiceState(state),
|
|
60
|
+
"agent_ids": agent_ids,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def get_token(self, service_id: int) -> str:
|
|
64
|
+
"""Get the token address for a service."""
|
|
65
|
+
return self.call("token", service_id)
|
|
66
|
+
|
|
67
|
+
def get_agent_params(self, service_id: int, agent_id: int) -> Dict:
|
|
68
|
+
"""Get agent params (slots, bond) for a service."""
|
|
69
|
+
(slots, bond) = self.call("getAgentParams", service_id, agent_id)
|
|
70
|
+
return {"slots": slots, "bond": bond}
|
|
71
|
+
|
|
72
|
+
def prepare_approve_tx(
|
|
73
|
+
self,
|
|
74
|
+
from_address: EthereumAddress,
|
|
75
|
+
spender: EthereumAddress,
|
|
76
|
+
id_: int,
|
|
77
|
+
) -> Optional[Dict]:
|
|
78
|
+
"""Approve."""
|
|
79
|
+
return self.prepare_transaction(
|
|
80
|
+
method_name="approve",
|
|
81
|
+
method_kwargs={"spender": spender, "id": id_},
|
|
82
|
+
tx_params={"from": from_address},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ServiceManagerContract(ContractInstance):
|
|
87
|
+
"""Class to interact with the service manager contract."""
|
|
88
|
+
|
|
89
|
+
name = "service_manager"
|
|
90
|
+
abi_path = OLAS_ABI_PATH / "service_manager.json"
|
|
91
|
+
|
|
92
|
+
def prepare_create_tx(
|
|
93
|
+
self,
|
|
94
|
+
from_address: EthereumAddress,
|
|
95
|
+
service_owner: EthereumAddress,
|
|
96
|
+
token_address: EthereumAddress,
|
|
97
|
+
config_hash: str,
|
|
98
|
+
agent_ids: list,
|
|
99
|
+
agent_params: list,
|
|
100
|
+
threshold: int,
|
|
101
|
+
) -> Optional[Dict]:
|
|
102
|
+
"""Create a new service."""
|
|
103
|
+
return self.prepare_transaction(
|
|
104
|
+
method_name="create",
|
|
105
|
+
method_kwargs={
|
|
106
|
+
"serviceOwner": service_owner,
|
|
107
|
+
"tokenAddress": token_address,
|
|
108
|
+
"configHash": config_hash,
|
|
109
|
+
"agentIds": agent_ids,
|
|
110
|
+
"agentParams": agent_params,
|
|
111
|
+
"threshold": threshold,
|
|
112
|
+
},
|
|
113
|
+
tx_params={"from": from_address},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def prepare_activate_registration_tx(
|
|
117
|
+
self,
|
|
118
|
+
from_address: EthereumAddress,
|
|
119
|
+
service_id: int,
|
|
120
|
+
value: int = 1,
|
|
121
|
+
) -> Optional[Dict]:
|
|
122
|
+
"""Activate registration for a service."""
|
|
123
|
+
tx = self.prepare_transaction(
|
|
124
|
+
method_name="activateRegistration",
|
|
125
|
+
method_kwargs={
|
|
126
|
+
"serviceId": service_id,
|
|
127
|
+
},
|
|
128
|
+
tx_params={"from": from_address, "value": value},
|
|
129
|
+
)
|
|
130
|
+
return tx
|
|
131
|
+
|
|
132
|
+
def prepare_register_agents_tx(
|
|
133
|
+
self,
|
|
134
|
+
from_address: EthereumAddress,
|
|
135
|
+
service_id: int,
|
|
136
|
+
agent_instances: list,
|
|
137
|
+
agent_ids: list,
|
|
138
|
+
value: int = 1,
|
|
139
|
+
) -> Optional[Dict]:
|
|
140
|
+
"""Register agents for a service."""
|
|
141
|
+
tx = self.prepare_transaction(
|
|
142
|
+
method_name="registerAgents",
|
|
143
|
+
method_kwargs={
|
|
144
|
+
"serviceId": service_id,
|
|
145
|
+
"agentInstances": agent_instances,
|
|
146
|
+
"agentIds": agent_ids,
|
|
147
|
+
},
|
|
148
|
+
tx_params={"from": from_address, "value": value},
|
|
149
|
+
)
|
|
150
|
+
return tx
|
|
151
|
+
|
|
152
|
+
def prepare_deploy_tx(
|
|
153
|
+
self,
|
|
154
|
+
from_address: EthereumAddress,
|
|
155
|
+
service_id: int,
|
|
156
|
+
multisig_implementation_address: Optional[str] = None,
|
|
157
|
+
fallback_handler: Optional[str] = None,
|
|
158
|
+
data: Optional[str] = None,
|
|
159
|
+
) -> Optional[Dict]:
|
|
160
|
+
"""Deploy a service."""
|
|
161
|
+
# Get addresses from chain if not provided
|
|
162
|
+
if not multisig_implementation_address:
|
|
163
|
+
multisig_implementation_address = self.chain_interface.get_contract_address(
|
|
164
|
+
"GNOSIS_SAFE_MULTISIG_IMPLEMENTATION"
|
|
165
|
+
)
|
|
166
|
+
if not fallback_handler:
|
|
167
|
+
fallback_handler = self.chain_interface.get_contract_address(
|
|
168
|
+
"GNOSIS_SAFE_FALLBACK_HANDLER"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if not multisig_implementation_address or not fallback_handler:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
"Multisig implementation or fallback handler address not found for chain"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
tx = self.prepare_transaction(
|
|
177
|
+
method_name="deploy",
|
|
178
|
+
method_kwargs={
|
|
179
|
+
"serviceId": service_id,
|
|
180
|
+
"multisigImplementationAddress": multisig_implementation_address,
|
|
181
|
+
"data": data or get_deployment_payload(fallback_handler),
|
|
182
|
+
},
|
|
183
|
+
tx_params={"from": from_address},
|
|
184
|
+
)
|
|
185
|
+
return tx
|
|
186
|
+
|
|
187
|
+
def prepare_terminate_tx(
|
|
188
|
+
self,
|
|
189
|
+
from_address: EthereumAddress,
|
|
190
|
+
service_id: int,
|
|
191
|
+
) -> Optional[Dict]:
|
|
192
|
+
"""Terminate a service."""
|
|
193
|
+
tx = self.prepare_transaction(
|
|
194
|
+
method_name="terminate",
|
|
195
|
+
method_kwargs={
|
|
196
|
+
"serviceId": service_id,
|
|
197
|
+
},
|
|
198
|
+
tx_params={"from": from_address},
|
|
199
|
+
)
|
|
200
|
+
return tx
|
|
201
|
+
|
|
202
|
+
def prepare_unbond_tx(
|
|
203
|
+
self,
|
|
204
|
+
from_address: EthereumAddress,
|
|
205
|
+
service_id: int,
|
|
206
|
+
) -> Optional[Dict]:
|
|
207
|
+
"""Terminate a service."""
|
|
208
|
+
tx = self.prepare_transaction(
|
|
209
|
+
method_name="unbond",
|
|
210
|
+
method_kwargs={
|
|
211
|
+
"serviceId": service_id,
|
|
212
|
+
},
|
|
213
|
+
tx_params={"from": from_address},
|
|
214
|
+
)
|
|
215
|
+
return tx
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Staking contract interaction.
|
|
2
|
+
|
|
3
|
+
=============================================================================
|
|
4
|
+
OLAS STAKING TOKEN MECHANICS
|
|
5
|
+
=============================================================================
|
|
6
|
+
|
|
7
|
+
When staking a service, the TOTAL OLAS required is split 50/50:
|
|
8
|
+
|
|
9
|
+
1. minStakingDeposit: Collateral for the staking contract
|
|
10
|
+
- Checked by stakingContract.minStakingDeposit()
|
|
11
|
+
- Goes to the staking contract when stake() is called
|
|
12
|
+
|
|
13
|
+
2. agentBond: Operator bond for the agent instance
|
|
14
|
+
- Must be deposited BEFORE staking during service creation
|
|
15
|
+
- Stored in Token Utility: getAgentBond(serviceId, agentId)
|
|
16
|
+
|
|
17
|
+
Both deposits are stored in the Token Utility contract:
|
|
18
|
+
- mapServiceIdTokenDeposit(serviceId) -> (token, deposit)
|
|
19
|
+
- getAgentBond(serviceId, agentId) -> bond
|
|
20
|
+
|
|
21
|
+
Example for Hobbyist 1 (100 OLAS total):
|
|
22
|
+
- minStakingDeposit: 50 OLAS
|
|
23
|
+
- agentBond: 50 OLAS (set during service creation)
|
|
24
|
+
- Total: 100 OLAS
|
|
25
|
+
|
|
26
|
+
The staking contract checks that:
|
|
27
|
+
1. Service is in DEPLOYED state
|
|
28
|
+
2. Service was created with the correct token (OLAS)
|
|
29
|
+
3. minStakingDeposit is met
|
|
30
|
+
4. Agent bond was deposited during service registration
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import math
|
|
35
|
+
import time
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from typing import Dict, List, Optional, Union
|
|
39
|
+
|
|
40
|
+
from iwa.core.contracts.contract import ContractInstance
|
|
41
|
+
from iwa.core.types import EthereumAddress
|
|
42
|
+
from iwa.plugins.olas.contracts.activity_checker import ActivityCheckerContract
|
|
43
|
+
from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StakingState(Enum):
|
|
49
|
+
"""Enum representing the staking state of a service."""
|
|
50
|
+
|
|
51
|
+
NOT_STAKED = 0
|
|
52
|
+
STAKED = 1
|
|
53
|
+
EVICTED = 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StakingContract(ContractInstance):
|
|
57
|
+
"""Class to interact with the staking contract.
|
|
58
|
+
|
|
59
|
+
Manages staking operations for OLAS services and tracks activity/liveness
|
|
60
|
+
requirements through the associated activity checker.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
name = "staking"
|
|
64
|
+
abi_path = OLAS_ABI_PATH / "staking.json"
|
|
65
|
+
|
|
66
|
+
def __init__(self, address: EthereumAddress, chain_name: str = "gnosis"):
|
|
67
|
+
"""Initialize StakingContract.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
address: The staking contract address.
|
|
71
|
+
chain_name: The chain name (default: gnosis).
|
|
72
|
+
|
|
73
|
+
Note:
|
|
74
|
+
minStakingDeposit is 50% of the total OLAS required.
|
|
75
|
+
The other 50% is the agentBond, deposited during service creation.
|
|
76
|
+
Example: Hobbyist 1 (100 OLAS) = 50 deposit + 50 bond
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
super().__init__(address, chain_name=chain_name)
|
|
80
|
+
self.chain_name = chain_name
|
|
81
|
+
self._contract_params_cache: Dict[str, int] = {}
|
|
82
|
+
|
|
83
|
+
# Get activity checker from the staking contract
|
|
84
|
+
activity_checker_address = self.call("activityChecker")
|
|
85
|
+
self.activity_checker = ActivityCheckerContract(
|
|
86
|
+
activity_checker_address, chain_name=chain_name
|
|
87
|
+
)
|
|
88
|
+
self.activity_checker_address = activity_checker_address
|
|
89
|
+
|
|
90
|
+
# Cache contract parameters
|
|
91
|
+
self.available_rewards = self.call("availableRewards")
|
|
92
|
+
self.balance = self.call("balance")
|
|
93
|
+
self.liveness_period = self.call("livenessPeriod")
|
|
94
|
+
self.rewards_per_second = self.call("rewardsPerSecond")
|
|
95
|
+
self.max_num_services = self.call("maxNumServices")
|
|
96
|
+
self.min_staking_deposit = self.call("minStakingDeposit")
|
|
97
|
+
self.min_staking_duration_hours = self.call("minStakingDuration") / 3600
|
|
98
|
+
self.staking_token_address = self.call("stakingToken")
|
|
99
|
+
|
|
100
|
+
def get_requirements(self) -> Dict[str, Union[str, int]]:
|
|
101
|
+
"""Get the contract requirements for token and deposits.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dict containing:
|
|
105
|
+
- staking_token: address of the token required
|
|
106
|
+
- min_staking_deposit: amount required to be paid during stake()
|
|
107
|
+
- required_agent_bond: amount required to be set during service creation
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
# For Olas Trader contracts (Hobbyist, Alpha, Beta, etc.),
|
|
111
|
+
# the total OLAS is split 50/50:
|
|
112
|
+
# 50% as agent bond (in registry) and 50% as staking deposit (passed to stake()).
|
|
113
|
+
return {
|
|
114
|
+
"staking_token": self.staking_token_address,
|
|
115
|
+
"min_staking_deposit": self.min_staking_deposit,
|
|
116
|
+
"required_agent_bond": self.min_staking_deposit,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def calculate_accrued_staking_reward(self, service_id: int) -> int:
|
|
120
|
+
"""Calculate the accrued staking reward for a given service ID."""
|
|
121
|
+
return self.call("calculateStakingLastReward", service_id)
|
|
122
|
+
|
|
123
|
+
def calculate_staking_reward(self, service_id: int) -> int:
|
|
124
|
+
"""Calculate the current staking reward for a given service ID."""
|
|
125
|
+
return self.call("calculateStakingReward", service_id)
|
|
126
|
+
|
|
127
|
+
def get_epoch_counter(self) -> int:
|
|
128
|
+
"""Get the current epoch counter from the staking contract."""
|
|
129
|
+
return self.call("epochCounter")
|
|
130
|
+
|
|
131
|
+
def get_next_epoch_start(self) -> datetime:
|
|
132
|
+
"""Calculate the start time of the next epoch."""
|
|
133
|
+
return datetime.fromtimestamp(
|
|
134
|
+
self.call("getNextRewardCheckpointTimestamp"),
|
|
135
|
+
tz=timezone.utc,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def get_service_ids(self) -> List[int]:
|
|
139
|
+
"""Get the current staked services."""
|
|
140
|
+
return self.call("getServiceIds")
|
|
141
|
+
|
|
142
|
+
def get_service_info(self, service_id: int) -> Dict:
|
|
143
|
+
"""Get comprehensive staking information for a service.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
service_id: The service ID to query.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict with staking info including nonces, rewards, and liveness status.
|
|
150
|
+
|
|
151
|
+
Note:
|
|
152
|
+
Activity nonces from the checker are: (safe_nonce, mech_requests_count).
|
|
153
|
+
For liveness tracking, we use mech_requests_count (index 1).
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
result = self.call("getServiceInfo", service_id)
|
|
157
|
+
# Handle potential nested tuple if web3 returns [(struct)]
|
|
158
|
+
if (
|
|
159
|
+
isinstance(result, (list, tuple))
|
|
160
|
+
and len(result) == 1
|
|
161
|
+
and isinstance(result[0], (list, tuple))
|
|
162
|
+
):
|
|
163
|
+
result = result[0]
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
(
|
|
167
|
+
multisig_address,
|
|
168
|
+
owner_address,
|
|
169
|
+
nonces_on_last_checkpoint,
|
|
170
|
+
ts_start,
|
|
171
|
+
accrued_reward,
|
|
172
|
+
inactivity,
|
|
173
|
+
) = result
|
|
174
|
+
except ValueError as e:
|
|
175
|
+
# Try to log useful info if unpacking fails
|
|
176
|
+
logger.error(
|
|
177
|
+
f"[Staking] Unpacking failed. Result type: {type(result)}, Result: {result}"
|
|
178
|
+
)
|
|
179
|
+
raise e
|
|
180
|
+
|
|
181
|
+
# Get current nonces from activity checker: (safe_nonce, mech_requests)
|
|
182
|
+
current_nonces = self.activity_checker.get_multisig_nonces(multisig_address)
|
|
183
|
+
current_safe_nonce, current_mech_requests = current_nonces
|
|
184
|
+
|
|
185
|
+
# Last checkpoint nonces are also (safe_nonce, mech_requests)
|
|
186
|
+
last_safe_nonce = nonces_on_last_checkpoint[0]
|
|
187
|
+
last_mech_requests = nonces_on_last_checkpoint[1]
|
|
188
|
+
|
|
189
|
+
# Mech requests this epoch (what matters for liveness)
|
|
190
|
+
mech_requests_this_epoch = current_mech_requests - last_mech_requests
|
|
191
|
+
|
|
192
|
+
required_requests = self.get_required_requests()
|
|
193
|
+
epoch_end = self.get_next_epoch_start()
|
|
194
|
+
remaining_seconds = (epoch_end - datetime.now(timezone.utc)).total_seconds()
|
|
195
|
+
|
|
196
|
+
# Check liveness ratio using activity checker
|
|
197
|
+
liveness_passed = self.is_liveness_ratio_passed(
|
|
198
|
+
current_nonces=current_nonces,
|
|
199
|
+
last_nonces=(last_safe_nonce, last_mech_requests),
|
|
200
|
+
ts_start=ts_start,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"multisig_address": multisig_address,
|
|
205
|
+
"owner_address": owner_address,
|
|
206
|
+
"current_safe_nonce": current_safe_nonce,
|
|
207
|
+
"current_mech_requests": current_mech_requests,
|
|
208
|
+
"last_checkpoint_safe_nonce": last_safe_nonce,
|
|
209
|
+
"last_checkpoint_mech_requests": last_mech_requests,
|
|
210
|
+
"mech_requests_this_epoch": mech_requests_this_epoch,
|
|
211
|
+
"required_mech_requests": required_requests,
|
|
212
|
+
"remaining_mech_requests": max(0, required_requests - mech_requests_this_epoch),
|
|
213
|
+
"has_enough_requests": mech_requests_this_epoch >= required_requests,
|
|
214
|
+
"accrued_reward_wei": accrued_reward,
|
|
215
|
+
"epoch_end_utc": epoch_end,
|
|
216
|
+
"remaining_epoch_seconds": remaining_seconds,
|
|
217
|
+
"liveness_ratio_passed": liveness_passed,
|
|
218
|
+
"ts_start": ts_start,
|
|
219
|
+
"inactivity_count": inactivity,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def get_staking_state(self, service_id: int) -> StakingState:
|
|
223
|
+
"""Get the staking state for a given service ID."""
|
|
224
|
+
return StakingState(self.call("getStakingState", service_id))
|
|
225
|
+
|
|
226
|
+
def ts_checkpoint(self) -> int:
|
|
227
|
+
"""Get the timestamp of the last checkpoint."""
|
|
228
|
+
return self.call("tsCheckpoint")
|
|
229
|
+
|
|
230
|
+
def get_required_requests(self, use_liveness_period: bool = True) -> int:
|
|
231
|
+
"""Calculate the required requests for the current epoch.
|
|
232
|
+
|
|
233
|
+
Includes a safety margin of 1 extra request.
|
|
234
|
+
"""
|
|
235
|
+
requests_safety_margin = 1
|
|
236
|
+
now_ts = time.time()
|
|
237
|
+
|
|
238
|
+
# If use_liveness_period is True, we show the requirement for a standard epoch
|
|
239
|
+
# instead of the potentially very long period since the last global checkpoint.
|
|
240
|
+
time_diff = (
|
|
241
|
+
self.liveness_period
|
|
242
|
+
if use_liveness_period
|
|
243
|
+
else max(self.liveness_period, now_ts - self.ts_checkpoint())
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return math.ceil(
|
|
247
|
+
(time_diff * self.activity_checker.liveness_ratio) / 1e18 + requests_safety_margin
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def is_liveness_ratio_passed(
|
|
251
|
+
self,
|
|
252
|
+
current_nonces: tuple,
|
|
253
|
+
last_nonces: tuple,
|
|
254
|
+
ts_start: int,
|
|
255
|
+
) -> bool:
|
|
256
|
+
"""Check if the liveness ratio requirement is passed.
|
|
257
|
+
|
|
258
|
+
Uses the activity checker's isRatioPass function to determine
|
|
259
|
+
if the service meets liveness requirements for staking rewards.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
current_nonces: Current (safe_nonce, mech_requests_count).
|
|
263
|
+
last_nonces: Nonces at the last checkpoint (safe_nonce, mech_requests_count).
|
|
264
|
+
ts_start: Timestamp when staking started or last checkpoint.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
True if liveness requirements are met.
|
|
268
|
+
|
|
269
|
+
"""
|
|
270
|
+
# Calculate time difference since last checkpoint
|
|
271
|
+
ts_diff = int(time.time()) - ts_start
|
|
272
|
+
if ts_diff <= 0:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
return self.activity_checker.is_ratio_pass(
|
|
276
|
+
current_nonces=current_nonces,
|
|
277
|
+
last_nonces=last_nonces,
|
|
278
|
+
ts_diff=ts_diff,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def min_staking_duration(self) -> int:
|
|
283
|
+
"""Get the minimum duration a service must be staked before it can be unstaked."""
|
|
284
|
+
if "minStakingDuration" not in self._contract_params_cache:
|
|
285
|
+
self._contract_params_cache["minStakingDuration"] = self.call("minStakingDuration")
|
|
286
|
+
return self._contract_params_cache["minStakingDuration"]
|
|
287
|
+
|
|
288
|
+
def prepare_stake_tx(
|
|
289
|
+
self,
|
|
290
|
+
from_address: EthereumAddress,
|
|
291
|
+
service_id: int,
|
|
292
|
+
) -> Optional[Dict]:
|
|
293
|
+
"""Prepare a stake transaction."""
|
|
294
|
+
tx = self.prepare_transaction(
|
|
295
|
+
method_name="stake",
|
|
296
|
+
method_kwargs={
|
|
297
|
+
"serviceId": service_id,
|
|
298
|
+
},
|
|
299
|
+
tx_params={"from": from_address},
|
|
300
|
+
)
|
|
301
|
+
return tx
|
|
302
|
+
|
|
303
|
+
def prepare_unstake_tx(
|
|
304
|
+
self,
|
|
305
|
+
from_address: EthereumAddress,
|
|
306
|
+
service_id: int,
|
|
307
|
+
) -> Optional[Dict]:
|
|
308
|
+
"""Prepare an unstake transaction."""
|
|
309
|
+
tx = self.prepare_transaction(
|
|
310
|
+
method_name="unstake",
|
|
311
|
+
method_kwargs={
|
|
312
|
+
"serviceId": service_id,
|
|
313
|
+
},
|
|
314
|
+
tx_params={"from": from_address},
|
|
315
|
+
)
|
|
316
|
+
return tx
|
|
317
|
+
|
|
318
|
+
def prepare_claim_tx(
|
|
319
|
+
self,
|
|
320
|
+
from_address: EthereumAddress,
|
|
321
|
+
service_id: int,
|
|
322
|
+
) -> Optional[Dict]:
|
|
323
|
+
"""Prepare a claim transaction to claim staking rewards.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
from_address: The address sending the transaction (service owner).
|
|
327
|
+
service_id: The service ID to claim rewards for.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Transaction dict ready to be signed and sent.
|
|
331
|
+
|
|
332
|
+
"""
|
|
333
|
+
tx = self.prepare_transaction(
|
|
334
|
+
method_name="claim",
|
|
335
|
+
method_kwargs={
|
|
336
|
+
"serviceId": service_id,
|
|
337
|
+
},
|
|
338
|
+
tx_params={"from": from_address},
|
|
339
|
+
)
|
|
340
|
+
return tx
|
|
341
|
+
|
|
342
|
+
def get_accrued_rewards(self, service_id: int) -> int:
|
|
343
|
+
"""Get accrued rewards for a service.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
service_id: The service ID to query.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Accrued rewards in wei (from mapServiceInfo[3]).
|
|
350
|
+
|
|
351
|
+
"""
|
|
352
|
+
service_info = self.call("mapServiceInfo", service_id)
|
|
353
|
+
# mapServiceInfo returns (multisig, owner, nonces, tsStart, reward, inactivity)
|
|
354
|
+
# reward is at index 4 (0-indexed)
|
|
355
|
+
return service_info[4] if len(service_info) > 4 else 0
|
|
356
|
+
|
|
357
|
+
def is_checkpoint_needed(self, grace_period_seconds: int = 600) -> bool:
|
|
358
|
+
"""Check if the checkpoint needs to be called.
|
|
359
|
+
|
|
360
|
+
The checkpoint should be called when:
|
|
361
|
+
1. The current epoch has ended (current time > epoch_end)
|
|
362
|
+
2. A grace period has passed (to allow someone else to call it first)
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
grace_period_seconds: Seconds to wait after epoch ends before calling.
|
|
366
|
+
Defaults to 600 (10 minutes).
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if checkpoint should be called, False otherwise.
|
|
370
|
+
|
|
371
|
+
"""
|
|
372
|
+
epoch_end = self.get_next_epoch_start()
|
|
373
|
+
now = datetime.now(timezone.utc)
|
|
374
|
+
|
|
375
|
+
# If the epoch has not finished, no need to call
|
|
376
|
+
if now < epoch_end:
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
# If less than grace_period has passed since epoch ended, wait
|
|
380
|
+
if (now - epoch_end).total_seconds() < grace_period_seconds:
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
def prepare_checkpoint_tx(self, from_address: str) -> Optional[Dict]:
|
|
386
|
+
"""Prepare a checkpoint transaction.
|
|
387
|
+
|
|
388
|
+
The checkpoint closes the current epoch and starts a new one.
|
|
389
|
+
Anyone can call this once the epoch has ended.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
from_address: The address sending the transaction.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Transaction dict ready to be signed and sent.
|
|
396
|
+
|
|
397
|
+
"""
|
|
398
|
+
tx = self.prepare_transaction(
|
|
399
|
+
method_name="checkpoint",
|
|
400
|
+
method_kwargs={},
|
|
401
|
+
tx_params={"from": from_address},
|
|
402
|
+
)
|
|
403
|
+
return tx
|